diff --git a/package-lock.json b/package-lock.json index 2b4ee5b..4d8ea6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,19 @@ "version": "1.0.0", "dependencies": { "bcrypt": "^5.1.1", + "bullmq": "^5.20.0", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.4.1", "express-validator": "^7.2.1", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", "pg": "^8.13.1", "pg-hstore": "^2.3.4", - "sequelize": "^6.37.3" + "sequelize": "^6.37.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "cross-env": "^7.0.3", @@ -26,6 +32,50 @@ "supertest": "^7.1.1" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -738,6 +788,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "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", @@ -1197,6 +1253,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -1217,6 +1279,84 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "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/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1240,6 +1380,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1369,7 +1516,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -1567,7 +1713,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -1732,6 +1877,24 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1878,6 +2041,34 @@ "dev": true, "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.63.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.63.2.tgz", + "integrity": "sha512-c1K5gcAh0a+C9lcRXaA1GePDYtmUywCH1pNXkUlZ8lFlqQnrtKyZpcr5aZJcjyZVx6y7t5259ru+ttJFNUQ5kw==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.8.2", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^11.1.0" + } + }, + "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", @@ -1916,6 +2107,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2069,6 +2266,15 @@ "node": ">=12" } }, + "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/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2129,6 +2335,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -2223,6 +2438,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "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-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -2322,6 +2549,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "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/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2381,6 +2617,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2710,7 +2958,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -2821,6 +3068,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", @@ -3468,6 +3730,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "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/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4266,7 +4552,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4445,18 +4730,44 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "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.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "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", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -4488,6 +4799,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -4504,6 +4821,15 @@ "yallist": "^3.0.2" } }, + "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": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -4723,12 +5049,86 @@ "node": "*" } }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "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/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4745,6 +5145,12 @@ "node": ">= 0.6" } }, + "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", @@ -4771,6 +5177,21 @@ } } }, + "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-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4921,6 +5342,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4946,6 +5376,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5516,6 +5953,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5987,6 +6445,12 @@ "node": ">=8" } }, + "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/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -6152,6 +6616,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -6241,6 +6782,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "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/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6550,6 +7097,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -6591,6 +7147,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/package.json b/package.json index 9befbd1..6381fdc 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,30 @@ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", - "test": "cross-env NODE_ENV=test jest --runInBand", + "test": "cross-env NODE_ENV=test jest --runInBand --setupFilesAfterEnv=./tests/setup.js", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "migrate": "node src/migrations/add-role-and-soft-delete.js" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["**/tests/**/*.test.js"] }, "dependencies": { "bcrypt": "^5.1.1", + "bullmq": "^5.20.0", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-rate-limit": "^7.4.1", "express-validator": "^7.2.1", + "ioredis": "^5.4.1", "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", "pg": "^8.13.1", "pg-hstore": "^2.3.4", - "sequelize": "^6.37.3" + "sequelize": "^6.37.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/src/app.js b/src/app.js index 2453aff..b0660a9 100644 --- a/src/app.js +++ b/src/app.js @@ -1,10 +1,32 @@ const express = require('express'); +const morgan = require('morgan'); +const swaggerUi = require('swagger-ui-express'); +const swaggerSpec = require('./config/swagger'); const app = express(); const authRoutes = require('./routes/authRoutes'); const taskRoutes = require('./routes/taskRoutes'); const errorHandler = require('./middleware/errorHandler'); +app.use(morgan('combined')); app.use(express.json()); + +// Serve Swagger JSON +app.get('/api/docs.json', (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); +}); + +// Serve Swagger UI +const swaggerOptions = { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'Task Manager API Documentation', + swaggerOptions: { + persistAuthorization: true, + }, +}; + +app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions)); + app.use('/api/auth', authRoutes); app.use('/api/tasks', taskRoutes); app.use(errorHandler); diff --git a/src/config/swagger.js b/src/config/swagger.js new file mode 100644 index 0000000..b3b554b --- /dev/null +++ b/src/config/swagger.js @@ -0,0 +1,43 @@ +const swaggerJsdoc = require('swagger-jsdoc'); +const path = require('path'); +require('dotenv').config(); + +const options = { + definition: { + openapi: '3.0.0', + info: { + title: 'Task Manager API', + version: '1.0.0', + description: 'A RESTful API for managing tasks with authentication, RBAC, and more', + }, + servers: [ + { + url: `http://localhost:${process.env.PORT || 4000}`, + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: [ + path.join(__dirname, '../routes/*.js'), + path.join(__dirname, '../controllers/*.js'), + ], +}; + +const swaggerSpec = swaggerJsdoc(options); + +module.exports = swaggerSpec; + diff --git a/src/controllers/authController.js b/src/controllers/authController.js index 92291a7..e016ef9 100644 --- a/src/controllers/authController.js +++ b/src/controllers/authController.js @@ -7,7 +7,9 @@ exports.register = async (req, res, next) => { if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); const { username, password } = req.body; const user = await authService.register(username, password); - res.status(201).json({ message: 'User created', user }); + const safeUser = user.get ? user.get({ plain: true }) : { ...user }; + delete safeUser.password; + res.status(201).json({ message: 'User created', user: safeUser }); } catch (err) { next(err); } }; diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js index f508afe..e6a9c70 100644 --- a/src/controllers/taskController.js +++ b/src/controllers/taskController.js @@ -1,10 +1,38 @@ const { validationResult } = require('express-validator'); -const { Task } = require('../models'); +const { Task, User } = require('../models'); +const { addEmailJob } = require('../services/queueService'); exports.getTasks = async (req, res, next) => { try { - const tasks = await Task.findAll({ where: { userId: req.user.id } }); - res.json(tasks); + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const status = req.query.status; + const offset = (page - 1) * limit; + + const whereClause = { deletedAt: null }; + if (req.user.role !== 'admin') { + whereClause.userId = req.user.id; + } + if (status) { + whereClause.status = status; + } + + const { count, rows: tasks } = await Task.findAndCountAll({ + where: whereClause, + limit, + offset, + order: [['createdAt', 'DESC']], + }); + + res.json({ + tasks, + pagination: { + page, + limit, + total: count, + totalPages: Math.ceil(count / limit), + }, + }); } catch (err) { next(err); } }; @@ -12,28 +40,53 @@ exports.createTask = async (req, res, next) => { try { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); - const { title, description } = req.body; - const task = await Task.create({ title, description, userId: req.user.id }); + const { title, description, status } = req.body; + const task = await Task.create({ title, description, status, userId: req.user.id }); + + // Send email notification in background + const user = await User.findByPk(req.user.id); + await addEmailJob({ + to: user.username, + subject: 'Task Created', + body: `Your task "${title}" has been created successfully.`, + }); + res.status(201).json(task); } catch (err) { next(err); } }; exports.updateTask = async (req, res, next) => { try { - const task = await Task.findByPk(req.params.id); + const task = await Task.findOne({ where: { id: req.params.id, deletedAt: null } }); if (!task) return res.status(404).json({ message: 'Task not found' }); - if (task.userId !== req.user.id) return res.status(403).json({ message: 'Forbidden' }); + if (req.user.role !== 'admin' && task.userId !== req.user.id) { + return res.status(403).json({ message: 'Forbidden' }); + } + + const oldStatus = task.status; await task.update(req.body); + + if (req.body.status === 'done' && oldStatus !== 'done') { + const user = await User.findByPk(task.userId); + await addEmailJob({ + to: user.username, + subject: 'Task Completed', + body: `Your task "${task.title}" has been marked as completed.`, + }); + } + res.json(task); } catch (err) { next(err); } }; exports.deleteTask = async (req, res, next) => { try { - const task = await Task.findByPk(req.params.id); + const task = await Task.findOne({ where: { id: req.params.id, deletedAt: null } }); if (!task) return res.status(404).json({ message: 'Task not found' }); - if (task.userId !== req.user.id) return res.status(403).json({ message: 'Forbidden' }); - await task.destroy(); + if (req.user.role !== 'admin' && task.userId !== req.user.id) { + return res.status(403).json({ message: 'Forbidden' }); + } + await task.update({ deletedAt: new Date() }); res.json({ message: 'Task deleted' }); } catch (err) { next(err); } }; \ No newline at end of file diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js new file mode 100644 index 0000000..c8785e5 --- /dev/null +++ b/src/middleware/rateLimiter.js @@ -0,0 +1,19 @@ +const rateLimit = require('express-rate-limit'); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + message: 'Too many login attempts, please try again after 15 minutes', + standardHeaders: true, + legacyHeaders: false, +}); + +// const apiLimiter = rateLimit({ +// windowMs: 15 * 60 * 1000, +// max: 100, +// standardHeaders: true, +// legacyHeaders: false, +// }); + +module.exports = { loginLimiter /*`, apiLimiter*/ }; + diff --git a/src/models/task.js b/src/models/task.js index 374907c..5754228 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -9,6 +9,9 @@ const Task = sequelize.define('Task', { type: DataTypes.ENUM('pending', 'in-progress', 'done'), defaultValue: 'pending', }, + deletedAt: { type: DataTypes.DATE, allowNull: true }, +}, { + paranoid: false, // We will handle soft deletes manually }); Task.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' }); diff --git a/src/models/user.js b/src/models/user.js index afebaad..4c9cd3c 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -5,6 +5,10 @@ const bcrypt = require('bcrypt'); const User = sequelize.define('User', { username: { type: DataTypes.STRING, allowNull: false, unique: true }, password: { type: DataTypes.STRING, allowNull: false }, + role: { + type: DataTypes.ENUM('user', 'admin'), + defaultValue: 'user', + }, }); User.beforeCreate(async (user) => { diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js index 34b61e8..6a198cf 100644 --- a/src/routes/authRoutes.js +++ b/src/routes/authRoutes.js @@ -2,7 +2,69 @@ const express = require('express'); const { body } = require('express-validator'); const router = express.Router(); const authController = require('../controllers/authController'); +const { loginLimiter } = require('../middleware/rateLimiter'); +/** + * @swagger + * /api/auth/register: + * post: + * summary: Register a new user + * tags: [Authentication] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - username + * - password + * properties: + * username: + * type: string + * password: + * type: string + * minLength: 5 + * responses: + * 201: + * description: User created successfully + * 400: + * description: Validation error + */ router.post('/register', [body('username').notEmpty(), body('password').isLength({ min: 5 })], authController.register); -router.post('/login', authController.login); + +/** + * @swagger + * /api/auth/login: + * post: + * summary: Login user + * tags: [Authentication] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - username + * - password + * properties: + * username: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * 401: + * description: Invalid credentials + */ +router.post('/login', loginLimiter, authController.login); module.exports = router; \ No newline at end of file diff --git a/src/routes/taskRoutes.js b/src/routes/taskRoutes.js index 264132a..31bf6db 100644 --- a/src/routes/taskRoutes.js +++ b/src/routes/taskRoutes.js @@ -5,8 +5,135 @@ const taskController = require('../controllers/taskController'); const auth = require('../middleware/authMiddleware'); router.use(auth); + +/** + * @swagger + * /api/tasks: + * get: + * summary: Get all tasks (with pagination and filtering) + * tags: [Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: Page number + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * description: Number of tasks per page + * - in: query + * name: status + * schema: + * type: string + * enum: [pending, in-progress, done] + * description: Filter by status + * responses: + * 200: + * description: List of tasks + * 401: + * description: Unauthorized + */ router.get('/', taskController.getTasks); + +/** + * @swagger + * /api/tasks: + * post: + * summary: Create a new task + * tags: [Tasks] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - title + * properties: + * title: + * type: string + * description: + * type: string + * status: + * type: string + * enum: [pending, in-progress, done] + * responses: + * 201: + * description: Task created successfully + * 400: + * description: Validation error + */ router.post('/', [body('title').notEmpty()], taskController.createTask); + +/** + * @swagger + * /api/tasks/{id}: + * put: + * summary: Update a task + * tags: [Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * description: + * type: string + * status: + * type: string + * enum: [pending, in-progress, done] + * responses: + * 200: + * description: Task updated successfully + * 404: + * description: Task not found + * 403: + * description: Forbidden + */ router.put('/:id', taskController.updateTask); + +/** + * @swagger + * /api/tasks/{id}: + * delete: + * summary: Delete a task (soft delete) + * tags: [Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Task ID + * responses: + * 200: + * description: Task deleted successfully + * 404: + * description: Task not found + * 403: + * description: Forbidden + */ router.delete('/:id', taskController.deleteTask); module.exports = router; \ No newline at end of file diff --git a/src/server.js b/src/server.js index ad3d796..c595598 100644 --- a/src/server.js +++ b/src/server.js @@ -2,11 +2,29 @@ require('dotenv').config(); const { sequelize } = require('./models'); const app = require('./app'); +// Start email worker in production or when explicitly enabled (skip in test environment) +if (process.env.NODE_ENV !== 'test' && process.env.ENABLE_WORKER !== 'false') { + try { + const emailWorker = require('./workers/emailWorker'); + if (emailWorker) { + console.log('Email worker started'); + } else { + console.warn('Email worker not available (Redis connection required)'); + console.warn('The application will continue to work, but email notifications will be disabled.'); + } + } catch (err) { + console.warn('Email worker could not be started (Redis may not be available):', err.message); + console.warn('The application will continue to work, but email notifications will be disabled.'); + } +} + const PORT = process.env.PORT || 4000; (async () => { try { - await sequelize.sync(); + // Use alter: true in development to add missing columns without dropping data + const syncOptions = process.env.NODE_ENV === 'production' ? {} : { alter: true }; + await sequelize.sync(syncOptions); app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`)); } catch (err) { console.error('DB Connection failed:', err); diff --git a/src/services/authService.js b/src/services/authService.js index 4865903..8d27290 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -5,7 +5,7 @@ const { User } = require('../models'); exports.register = async (username, password) => { const existing = await User.findOne({ where: { username } }); if (existing) throw new Error('Username already exists'); - return await User.create({ username, password }); + return await User.create({ username, password}); }; exports.login = async (username, password) => { @@ -14,7 +14,7 @@ exports.login = async (username, password) => { const valid = await bcrypt.compare(password, user.password); if (!valid) throw new Error('Invalid credentials'); const token = jwt.sign( - { id: user.id, username: user.username }, + { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' } ); diff --git a/src/services/queueService.js b/src/services/queueService.js new file mode 100644 index 0000000..63407a0 --- /dev/null +++ b/src/services/queueService.js @@ -0,0 +1,69 @@ +const { Queue } = require('bullmq'); +const Redis = require('ioredis'); + +let emailQueue = null; +let connection = null; + +const createRedisConnection = () => { + return new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + maxRetriesPerRequest: null, + retryStrategy: (times) => { + + return null; + }, + lazyConnect: true, + enableOfflineQueue: false, + }); +}; + +if (process.env.NODE_ENV !== 'test') { + try { + connection = createRedisConnection(); + + connection.on('error', (err) => { + console.warn('Redis connection error:', err.message); + emailQueue = null; + }); + + connection.on('connect', () => { + console.log('Redis connected successfully'); + }); + + connection.connect().catch((err) => { + console.warn('Redis connection failed, email queue disabled:', err.message); + console.warn('To enable email notifications, please install and start Redis:'); + console.warn(' macOS: brew install redis && brew services start redis'); + console.warn(' Linux: sudo apt-get install redis-server && sudo systemctl start redis'); + emailQueue = null; + }); + + emailQueue = new Queue('email', { connection }); + } catch (err) { + console.warn('Redis initialization failed, email queue disabled:', err.message); + emailQueue = null; + } +} + +const addEmailJob = async (jobData) => { + if (!emailQueue || !connection || connection.status !== 'ready') { + return null; + } + + try { + return await emailQueue.add('send-email', jobData, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }); + } catch (err) { + console.warn('Failed to add email job to queue:', err.message); + return null; + } +}; + +module.exports = { addEmailJob, emailQueue }; + diff --git a/src/workers/emailWorker.js b/src/workers/emailWorker.js new file mode 100644 index 0000000..382a2ed --- /dev/null +++ b/src/workers/emailWorker.js @@ -0,0 +1,81 @@ +const { Worker } = require('bullmq'); +const Redis = require('ioredis'); + +let connection = null; +let emailWorker = null; + +try { + connection = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + maxRetriesPerRequest: null, + retryStrategy: (times) => { + // Don't retry if connection fails + return null; + }, + lazyConnect: true, + enableOfflineQueue: false, + }); + + connection.on('error', (err) => { + console.error('Email worker Redis connection error:', err.message); + }); + + connection.on('connect', () => { + console.log('Email worker Redis connected'); + }); + + // Try to connect + connection.connect().catch((err) => { + console.warn('Email worker: Redis connection failed:', err.message); + console.warn('Email notifications will not be processed until Redis is available'); + }); +} catch (err) { + console.warn('Email worker: Failed to initialize Redis:', err.message); +} + +// This is a placeholder email sending function +// In production, you would integrate with an email service like SendGrid, AWS SES, etc. +const sendEmail = async (to, subject, body) => { + console.log(`[EMAIL] Sending email to: ${to}`); + console.log(`[EMAIL] Subject: ${subject}`); + console.log(`[EMAIL] Body: ${body}`); + // Simulate email sending + return new Promise((resolve) => { + setTimeout(() => { + console.log(`[EMAIL] Email sent successfully to: ${to}`); + resolve(); + }, 1000); + }); +}; + +// Only create worker if Redis connection is available +if (connection) { + try { + emailWorker = new Worker( + 'email', + async (job) => { + const { to, subject, body } = job.data; + await sendEmail(to, subject, body); + }, + { connection } + ); + + emailWorker.on('completed', (job) => { + console.log(`[WORKER] Email job ${job.id} completed`); + }); + + emailWorker.on('failed', (job, err) => { + console.error(`[WORKER] Email job ${job.id} failed:`, err.message); + }); + + emailWorker.on('error', (err) => { + console.error('[WORKER] Email worker error:', err.message); + }); + } catch (err) { + console.warn('Email worker: Failed to create worker:', err.message); + } +} + +module.exports = emailWorker; + diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..67292cc --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,117 @@ +const request = require('supertest'); +const app = require('../src/app'); +const { User } = require('../src/models'); + +describe('Authentication API', () => { + beforeEach(async () => { + + await User.destroy({ where: {}, force: true }); + }); + + describe('POST /api/auth/register', () => { + it('should register a new user successfully', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123', + }); + + expect(res.statusCode).toBe(201); + expect(res.body).toHaveProperty('message', 'User created'); + expect(res.body).toHaveProperty('user'); + expect(res.body.user).toHaveProperty('username', 'testuser'); + expect(res.body.user).not.toHaveProperty('password'); + }); + + it('should return 400 if username is missing', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + password: 'password123', + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty('errors'); + }); + + it('should return 400 if password is too short', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: '1234', + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty('errors'); + }); + + it('should return error if username already exists', async () => { + await User.create({ username: 'testuser', password: 'password123' }); + + const res = await request(app) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123', + }); + + expect(res.statusCode).toBe(500); + }); + }); + + describe('POST /api/auth/login', () => { + beforeEach(async () => { + await User.create({ username: 'testuser', password: 'password123' }); + }); + + it('should login successfully with valid credentials', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123', + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('token'); + expect(typeof res.body.token).toBe('string'); + }); + + it('should return error with invalid username', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ + username: 'nonexistent', + password: 'password123', + }); + + expect(res.statusCode).toBe(500); + }); + + it('should return error with invalid password', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'wrongpassword', + }); + + expect(res.statusCode).toBe(500); + }); + + it('should include role in JWT token', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123', + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('token'); + }); + }); +}); + diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..47ce024 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,18 @@ +require('dotenv').config(); +const { sequelize } = require('../src/models'); + +// Set default JWT_SECRET for tests if not provided +if (!process.env.JWT_SECRET) { + process.env.JWT_SECRET = 'test-secret-key'; +} + +beforeAll(async () => { + // Sync database before tests + await sequelize.sync({ force: true }); +}); + +afterAll(async () => { + // Close database connection after tests + await sequelize.close(); +}); + diff --git a/tests/task.test.js b/tests/task.test.js index 8db0828..6e56908 100644 --- a/tests/task.test.js +++ b/tests/task.test.js @@ -1,9 +1,268 @@ const request = require('supertest'); const app = require('../src/app'); +const { User, Task } = require('../src/models'); +const jwt = require('jsonwebtoken'); describe('Task API', () => { - it('GET /api/tasks should return 401 if no token provided', async () => { - const res = await request(app).get('/api/tasks'); - expect(res.statusCode).toBe(401); + let userToken; + let adminToken; + let userId; + let adminId; + + beforeAll(async () => { + // Create test users + const user = await User.create({ username: 'testuser', password: 'password123', role: 'user' }); + const admin = await User.create({ username: 'admin', password: 'password123', role: 'admin' }); + userId = user.id; + adminId = admin.id; + userToken = jwt.sign({ id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET || 'test-secret'); + adminToken = jwt.sign({ id: admin.id, username: admin.username, role: admin.role }, process.env.JWT_SECRET || 'test-secret'); + }); + + beforeEach(async () => { + // Clean up tasks before each test + await Task.destroy({ where: {}, force: true }); + }); + + describe('GET /api/tasks', () => { + it('should return 401 if no token provided', async () => { + const res = await request(app).get('/api/tasks'); + expect(res.statusCode).toBe(401); + }); + + it('should return empty array for user with no tasks', async () => { + const res = await request(app) + .get('/api/tasks') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('tasks'); + expect(res.body).toHaveProperty('pagination'); + expect(res.body.tasks).toHaveLength(0); + }); + + it('should return user tasks with pagination', async () => { + // Create test tasks + await Task.create({ title: 'Task 1', userId, status: 'pending' }); + await Task.create({ title: 'Task 2', userId, status: 'in-progress' }); + await Task.create({ title: 'Task 3', userId, status: 'done' }); + + const res = await request(app) + .get('/api/tasks?page=1&limit=2') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.tasks).toHaveLength(2); + expect(res.body.pagination).toEqual({ + page: 1, + limit: 2, + total: 3, + totalPages: 2, + }); + }); + + it('should filter tasks by status', async () => { + await Task.create({ title: 'Task 1', userId, status: 'pending' }); + await Task.create({ title: 'Task 2', userId, status: 'done' }); + await Task.create({ title: 'Task 3', userId, status: 'pending' }); + + const res = await request(app) + .get('/api/tasks?status=pending') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.tasks).toHaveLength(2); + expect(res.body.tasks.every(task => task.status === 'pending')).toBe(true); + }); + + it('should allow admin to see all tasks', async () => { + // Create tasks for different users + await Task.create({ title: 'User Task', userId, status: 'pending' }); + await Task.create({ title: 'Admin Task', userId: adminId, status: 'pending' }); + + const res = await request(app) + .get('/api/tasks') + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.tasks.length).toBeGreaterThanOrEqual(2); + }); + + it('should exclude soft-deleted tasks', async () => { + const task = await Task.create({ title: 'Task 1', userId, status: 'pending' }); + await task.update({ deletedAt: new Date() }); + + const res = await request(app) + .get('/api/tasks') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.tasks).toHaveLength(0); + }); + }); + + describe('POST /api/tasks', () => { + it('should create a new task', async () => { + const res = await request(app) + .post('/api/tasks') + .set('Authorization', `Bearer ${userToken}`) + .send({ + title: 'New Task', + description: 'Task description', + }); + + expect(res.statusCode).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body).toHaveProperty('title', 'New Task'); + expect(res.body).toHaveProperty('description', 'Task description'); + expect(res.body).toHaveProperty('status', 'pending'); + expect(res.body).toHaveProperty('userId', userId); + }); + + it('should return 400 if title is missing', async () => { + const res = await request(app) + .post('/api/tasks') + .set('Authorization', `Bearer ${userToken}`) + .send({ + description: 'Task description', + }); + + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty('errors'); + }); + + it('should return 401 if no token provided', async () => { + const res = await request(app) + .post('/api/tasks') + .send({ + title: 'New Task', + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/tasks/:id', () => { + let taskId; + + beforeEach(async () => { + const task = await Task.create({ title: 'Test Task', userId, status: 'pending' }); + taskId = task.id; + }); + + it('should update a task', async () => { + const res = await request(app) + .put(`/api/tasks/${taskId}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ + title: 'Updated Task', + status: 'in-progress', + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('title', 'Updated Task'); + expect(res.body).toHaveProperty('status', 'in-progress'); + }); + + it('should return 404 if task not found', async () => { + const res = await request(app) + .put('/api/tasks/99999') + .set('Authorization', `Bearer ${userToken}`) + .send({ + title: 'Updated Task', + }); + + expect(res.statusCode).toBe(404); + }); + + it('should return 403 if user tries to update another user task', async () => { + const otherUser = await User.create({ username: 'otheruser', password: 'password123', role: 'user' }); + const otherTask = await Task.create({ title: 'Other Task', userId: otherUser.id }); + const otherToken = jwt.sign({ id: otherUser.id, username: otherUser.username, role: otherUser.role }, process.env.JWT_SECRET || 'test-secret'); + + const res = await request(app) + .put(`/api/tasks/${otherTask.id}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ + title: 'Updated Task', + }); + + expect(res.statusCode).toBe(403); + }); + + it('should allow admin to update any task', async () => { + const res = await request(app) + .put(`/api/tasks/${taskId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Admin Updated Task', + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('title', 'Admin Updated Task'); + }); + }); + + describe('DELETE /api/tasks/:id', () => { + let taskId; + + beforeEach(async () => { + const task = await Task.create({ title: 'Test Task', userId, status: 'pending' }); + taskId = task.id; + }); + + it('should soft delete a task', async () => { + const res = await request(app) + .delete(`/api/tasks/${taskId}`) + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('message', 'Task deleted'); + + // Verify task is soft deleted + const deletedTask = await Task.findByPk(taskId); + expect(deletedTask.deletedAt).not.toBeNull(); + }); + + it('should return 404 if task not found', async () => { + const res = await request(app) + .delete('/api/tasks/99999') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(404); + }); + + it('should return 403 if user tries to delete another user task', async () => { + const otherUser = await User.create({ username: 'otheruser2', password: 'password123', role: 'user' }); + const otherTask = await Task.create({ title: 'Other Task', userId: otherUser.id }); + + const res = await request(app) + .delete(`/api/tasks/${otherTask.id}`) + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(403); + }); + + it('should allow admin to delete any task', async () => { + const res = await request(app) + .delete(`/api/tasks/${taskId}`) + .set('Authorization', `Bearer ${adminToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('message', 'Task deleted'); + }); + + it('should not return soft-deleted task in GET requests', async () => { + await request(app) + .delete(`/api/tasks/${taskId}`) + .set('Authorization', `Bearer ${userToken}`); + + const res = await request(app) + .get('/api/tasks') + .set('Authorization', `Bearer ${userToken}`); + + expect(res.statusCode).toBe(200); + expect(res.body.tasks).toHaveLength(0); + }); }); -}); \ No newline at end of file +});