diff --git a/.gitignore b/.gitignore index 821945f..8f37d54 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ bun.lockb !.vscode/launch.json !.vscode/extensions.json -.env \ No newline at end of file +.env +igaq-google-application-credentials.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6e887aa..8f7c71f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,17 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@google-cloud/recaptcha-enterprise": "^3.1.1", "@nestjs/axios": "^0.1.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", + "@nestjs/event-emitter": "^1.3.1", "@nestjs/jwt": "^9.0.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/swagger": "^6.1.2", + "@nestjs/throttler": "^3.1.0", "@prisma/client": "^4.4.0", "bcrypt": "^5.1.0", "cache-manager": "^4.0.0", @@ -26,6 +29,7 @@ "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", + "pusher": "^5.1.1-beta", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", @@ -559,7 +563,6 @@ "version": "7.19.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz", "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -881,6 +884,72 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@google-cloud/recaptcha-enterprise": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/recaptcha-enterprise/-/recaptcha-enterprise-3.1.1.tgz", + "integrity": "sha512-/DeLqdQPQzBENpxsQaZSgjzuCj1wmpTUi7J+EdDTexzn3MynALKm+9mAfrU+xAcoXHSEwLdHgANcNdPdxSbmeQ==", + "dependencies": { + "google-gax": "^3.5.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.3.tgz", + "integrity": "sha512-H9l79u4kJ2PVSxUNA08HMYAnUBLj9v6KjYQ7SQ71hOZcEXhShE/y5iQCesP8+6/Ik/7i2O0a10bPquIcYfufog==", + "dependencies": { + "@grpc/proto-loader": "^0.7.0", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.3.tgz", + "integrity": "sha512-5dAvoZwna2Py3Ef96Ux9jIkp3iZ62TUsV00p3wVBPNX5K178UbNi8Q7gQVqwXT1Yq9RejIGG9G2IPEo93T6RcA==", + "dependencies": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^7.0.0", + "yargs": "^16.2.0" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", @@ -1624,6 +1693,19 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-1.3.1.tgz", + "integrity": "sha512-AmHkPTe/cP1lbQEm15TIe9IDEAszl5VAR8HjMS2TDtNRuSzwyoJgZUVcRnH7Yk9/2DX5qMtmw6a1MHeR8DD+rw==", + "dependencies": { + "eventemitter2": "6.4.6" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/jwt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", @@ -1838,6 +1920,19 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-3.1.0.tgz", + "integrity": "sha512-u9a5+rci6ybYtJ2is6gZWxE2dMZEpnK0qJ0C1OnchuNCvM21Bg6qym1TB6Uihhci+JfTv6E15WuASLXcIclsbA==", + "dependencies": { + "md5": "^2.2.1" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0", + "reflect-metadata": "^0.1.13" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1930,6 +2025,60 @@ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.4.0-66.f352a33b70356f46311da8b00d83386dd9f145d6.tgz", "integrity": "sha512-P5v/PuEIJLYXZUZBvOLPqoyCW+m6StNqHdiR6te++gYVODpPdLakks5HVx3JaZIY+LwR02juJWFlwpc9Eog/ug==" }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@sinclair/typebox": { "version": "0.24.43", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.43.tgz", @@ -2160,6 +2309,30 @@ "@types/node": "*" } }, + "node_modules/@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==" + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -2639,6 +2812,17 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2655,7 +2839,6 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -2676,7 +2859,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2865,6 +3047,14 @@ "node": ">=8" } }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3034,6 +3224,14 @@ "node": ">= 10.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.0.tgz", + "integrity": "sha512-4LwHK4nfDOraBCtst+wOWIHbu1vhvAPJK8g8nROd4iuc3PSEjWif/qwbkh8jwCJz6yDBvtU4KPynETgrfh7y3A==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3068,6 +3266,11 @@ "node": ">= 6" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "node_modules/body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -3291,6 +3494,17 @@ } ] }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -3319,6 +3533,14 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3441,7 +3663,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -3658,6 +3879,14 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3683,8 +3912,7 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "node_modules/deepmerge": { "version": "4.2.2", @@ -3752,9 +3980,9 @@ } }, "node_modules/dezalgo": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", - "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, "dependencies": { "asap": "^2.0.0", @@ -3819,6 +4047,30 @@ "node": ">=12" } }, + "node_modules/duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3867,7 +4119,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "dependencies": { "once": "^1.4.0" } @@ -3885,6 +4136,14 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3904,7 +4163,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } @@ -3926,6 +4184,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz", @@ -4059,7 +4394,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -4140,7 +4474,6 @@ "version": "9.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz", "integrity": "sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==", - "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", @@ -4157,7 +4490,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -4212,7 +4544,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, "engines": { "node": ">=4.0" } @@ -4221,7 +4552,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4234,6 +4564,19 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.6.tgz", + "integrity": "sha512-OHqo4wbHX5VbvlbB6o6eDwhYmiTjrpWACjF8Pmof/GTD6rdBNdZFNck3xlhqOiQFGCOoq3uzHvA0cQpFHIGVAQ==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4350,6 +4693,11 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -4401,14 +4749,18 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-text-encoding": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", + "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" + }, "node_modules/fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -4623,25 +4975,28 @@ } }, "node_modules/formidable": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", - "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", + "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", "dev": true, "dependencies": { - "dezalgo": "1.0.3", - "hexoid": "1.0.0", - "once": "1.4.0", - "qs": "6.9.3" + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/formidable/node_modules/qs": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { "node": ">=0.6" }, @@ -4745,6 +5100,32 @@ "node": ">=10" } }, + "node_modules/gaxios": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.0.2.tgz", + "integrity": "sha512-TjtV2AJOZoMQqRYoy5eM8cCQogYwazWNYLQ72QB0kwa6vHHruYkGmhhyrlzbmgNHK1dNnuP2WSH81urfzyN2Og==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.7" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.0.1.tgz", + "integrity": "sha512-jiRJ+Fk7e8FH68Z6TLaqwea307OktJpDjmYnU7/li6ziwvVvU2RlrCyQo5vkdeP94chm0kcSCOOszvmuaioq3g==", + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4758,7 +5139,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -4869,17 +5249,128 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz", + "integrity": "sha512-1M0NG5VDIvJZEnstHbRdckLZESoJwguinwN8Dhae0j2ZKIQFIV63zxm6Fo6nM4xkgqUr2bbMtV5Dgo+Hy6oo0Q==", + "dependencies": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^5.0.0", + "gcp-metadata": "^5.0.0", + "gtoken": "^6.1.0", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-gax": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-3.5.2.tgz", + "integrity": "sha512-AyP53w0gHcWlzxm+jSgqCR3Xu4Ld7EpSjhtNBnNhzwwWaIUyphH9kBGNIEH+i4UGkTUXOY29K/Re8EiAvkBRGw==", + "dependencies": { + "@grpc/grpc-js": "~1.7.0", + "@grpc/proto-loader": "^0.7.0", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "fast-text-encoding": "^1.0.3", + "google-auth-library": "^8.0.2", + "is-stream-ended": "^0.1.4", + "node-fetch": "^2.6.1", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^1.0.0", + "protobufjs": "7.1.2", + "protobufjs-cli": "1.0.2", + "retry-request": "^5.0.0" + }, + "bin": { + "compileProtos": "build/tools/compileProtos.js", + "minifyProtoJson": "build/tools/minify.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/google-p12-pem": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-4.0.1.tgz", + "integrity": "sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==", + "dependencies": { + "node-forge": "^1.3.1" + }, + "bin": { + "gp12-pem": "build/src/bin/gp12-pem.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true + "node_modules/gtoken": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-6.1.2.tgz", + "integrity": "sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==", + "dependencies": { + "gaxios": "^5.0.1", + "google-p12-pem": "^4.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } }, "node_modules/has": { "version": "1.0.3", @@ -5145,6 +5636,15 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-base64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-1.1.0.tgz", + "integrity": "sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==", + "bin": { + "is_base64": "bin/is-base64", + "is-base64": "bin/is-base64" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5157,6 +5657,11 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-core-module": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", @@ -5229,7 +5734,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "engines": { "node": ">=8" }, @@ -5237,6 +5741,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6167,6 +6676,61 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "3.6.11", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.11.tgz", + "integrity": "sha512-8UCU0TYeIYD9KeLzEcAu2q8N/mx9O3phAGl32nmHlE0LpaJL71mMkP4d+QE5zWfNt50qheHtOZ0qoxVrsX5TUg==", + "dependencies": { + "@babel/parser": "^7.9.4", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "taffydb": "2.6.2", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsdoc/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -6179,6 +6743,14 @@ "node": ">=4" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6275,6 +6847,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6317,6 +6897,14 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -6346,6 +6934,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -6430,6 +7023,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -6502,6 +7100,56 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.5", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.5.tgz", + "integrity": "sha512-PI1qEHHkTNWT+X6Ip9w+paonfIQ+QZP9sCeMYi47oqhH+EsW8CrJ8J7CzV19QVOj6il8ATGbK2nTECj22ZHGvQ==", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/marked": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.2.3.tgz", + "integrity": "sha512-slWRdJkbTZ+PjkyJnE30Uid64eHwbwa1Q25INCAYfZlK4o6ylagBy/Le9eWntqJFoFT93ikUKMv47GZ4gTwHkw==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6796,6 +7444,14 @@ } } }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7347,6 +8003,143 @@ "node": ">= 6" } }, + "node_modules/proto3-json-serializer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.0.tgz", + "integrity": "sha512-SjXwUWe/vANGs/mJJTbw5++7U67nwsymg7qsoPtw6GiXqw3kUy8ByojrlEdVE2efxAdKreX8WkDafxvYW95ZQg==", + "dependencies": { + "protobufjs": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.2.tgz", + "integrity": "sha512-4ZPTPkXCdel3+L81yw3dG6+Kq3umdWKh7Dc7GW/CpNk4SX3hK58iPCWeCyhVTDrbkNeKrYNZ7EojM5WDaEWTLQ==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs-cli": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.0.2.tgz", + "integrity": "sha512-cz9Pq9p/Zs7okc6avH20W7QuyjTclwJPgqXG11jNaulfS3nbVisID8rC+prfgq0gbZE0w9LBFd1OKFF03kgFzg==", + "dependencies": { + "chalk": "^4.0.0", + "escodegen": "^1.13.0", + "espree": "^9.0.0", + "estraverse": "^5.1.0", + "glob": "^8.0.0", + "jsdoc": "^3.6.3", + "minimist": "^1.2.0", + "semver": "^7.1.2", + "tmp": "^0.2.1", + "uglify-js": "^3.7.7" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "protobufjs": "^7.0.0" + } + }, + "node_modules/protobufjs-cli/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/protobufjs-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/protobufjs-cli/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/protobufjs-cli/node_modules/glob": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", + "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/protobufjs-cli/node_modules/minimatch": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.1.tgz", + "integrity": "sha512-362NP+zlprccbEt/SkxKfRMHnNY85V74mVnpUpNyr3F35covl09Kec7/sEFLt3RA4oXmewtoaanoIf67SE5Y5g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/protobufjs-cli/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.1.tgz", + "integrity": "sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7378,6 +8171,21 @@ "node": ">=6" } }, + "node_modules/pusher": { + "version": "5.1.1-beta", + "resolved": "https://registry.npmjs.org/pusher/-/pusher-5.1.1-beta.tgz", + "integrity": "sha512-kGzseVc0MM5kJ6d5TFbtZXeBqDI1enkDq5QjfviEly9/57RSCiY/965ve6IJ5NbMp7T8ZPCqg82FIAkJ+zFxNg==", + "dependencies": { + "abort-controller": "^3.0.0", + "is-base64": "^1.1.0", + "node-fetch": "^2.6.1", + "tweetnacl": "^1.0.0", + "tweetnacl-util": "^0.15.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -7518,7 +8326,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7532,6 +8339,14 @@ "node": ">=0.10.0" } }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -7601,6 +8416,18 @@ "node": ">=8" } }, + "node_modules/retry-request": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", + "integrity": "sha512-wfI3pk7EE80lCIXprqh7ym48IHYdwmAAzESdbU8Q9l7pnRCk9LEhpbOTNKjz6FARLm/Bl5m+4F0ABxOkYUujSQ==", + "dependencies": { + "debug": "^4.1.1", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -7966,6 +8793,11 @@ "node": ">= 0.8" } }, + "node_modules/stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -8046,7 +8878,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "engines": { "node": ">=8" }, @@ -8165,6 +8996,11 @@ "node": ">=0.10" } }, + "node_modules/taffydb": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz", + "integrity": "sha512-y3JaeRSplks6NYQuCOj3ZFMO3j60rTwbuKCvZxsAraGYH2epusatvZ0baZYA01WsGqJBq/Dl6vOrMUJqyMj8kA==" + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -8591,6 +9427,16 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8654,6 +9500,27 @@ "node": ">=4.2.0" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -8968,7 +9835,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8977,7 +9843,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9008,6 +9873,11 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9020,7 +9890,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -9473,8 +10342,7 @@ "@babel/parser": { "version": "7.19.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz", - "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==", - "dev": true + "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==" }, "@babel/plugin-syntax-async-generators": { "version": "7.8.4", @@ -9720,6 +10588,56 @@ } } }, + "@google-cloud/recaptcha-enterprise": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/recaptcha-enterprise/-/recaptcha-enterprise-3.1.1.tgz", + "integrity": "sha512-/DeLqdQPQzBENpxsQaZSgjzuCj1wmpTUi7J+EdDTexzn3MynALKm+9mAfrU+xAcoXHSEwLdHgANcNdPdxSbmeQ==", + "requires": { + "google-gax": "^3.5.2" + } + }, + "@grpc/grpc-js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.3.tgz", + "integrity": "sha512-H9l79u4kJ2PVSxUNA08HMYAnUBLj9v6KjYQ7SQ71hOZcEXhShE/y5iQCesP8+6/Ik/7i2O0a10bPquIcYfufog==", + "requires": { + "@grpc/proto-loader": "^0.7.0", + "@types/node": ">=12.12.47" + } + }, + "@grpc/proto-loader": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.3.tgz", + "integrity": "sha512-5dAvoZwna2Py3Ef96Ux9jIkp3iZ62TUsV00p3wVBPNX5K178UbNi8Q7gQVqwXT1Yq9RejIGG9G2IPEo93T6RcA==", + "requires": { + "@types/long": "^4.0.1", + "lodash.camelcase": "^4.3.0", + "long": "^4.0.0", + "protobufjs": "^7.0.0", + "yargs": "^16.2.0" + }, + "dependencies": { + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==" + } + } + }, "@humanwhocodes/config-array": { "version": "0.10.5", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", @@ -10272,6 +11190,14 @@ "uuid": "9.0.0" } }, + "@nestjs/event-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-1.3.1.tgz", + "integrity": "sha512-AmHkPTe/cP1lbQEm15TIe9IDEAszl5VAR8HjMS2TDtNRuSzwyoJgZUVcRnH7Yk9/2DX5qMtmw6a1MHeR8DD+rw==", + "requires": { + "eventemitter2": "6.4.6" + } + }, "@nestjs/jwt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-9.0.0.tgz", @@ -10414,6 +11340,14 @@ "tslib": "2.4.0" } }, + "@nestjs/throttler": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-3.1.0.tgz", + "integrity": "sha512-u9a5+rci6ybYtJ2is6gZWxE2dMZEpnK0qJ0C1OnchuNCvM21Bg6qym1TB6Uihhci+JfTv6E15WuASLXcIclsbA==", + "requires": { + "md5": "^2.2.1" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10474,6 +11408,60 @@ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-4.4.0-66.f352a33b70356f46311da8b00d83386dd9f145d6.tgz", "integrity": "sha512-P5v/PuEIJLYXZUZBvOLPqoyCW+m6StNqHdiR6te++gYVODpPdLakks5HVx3JaZIY+LwR02juJWFlwpc9Eog/ug==" }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "@sinclair/typebox": { "version": "0.24.43", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.43.tgz", @@ -10701,9 +11689,33 @@ "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", "dev": true, "requires": { - "@types/node": "*" + "@types/node": "*" + } + }, + "@types/linkify-it": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.2.tgz", + "integrity": "sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==" + }, + "@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "requires": { + "@types/linkify-it": "*", + "@types/mdurl": "*" } }, + "@types/mdurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz", + "integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==" + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -11094,6 +12106,14 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -11106,8 +12126,7 @@ "acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", - "dev": true + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==" }, "acorn-import-assertions": { "version": "1.8.0", @@ -11120,7 +12139,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "requires": {} }, "acorn-walk": { @@ -11257,6 +12275,11 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + }, "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -11383,6 +12406,11 @@ "node-addon-api": "^5.0.0" } }, + "bignumber.js": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.0.tgz", + "integrity": "sha512-4LwHK4nfDOraBCtst+wOWIHbu1vhvAPJK8g8nROd4iuc3PSEjWif/qwbkh8jwCJz6yDBvtU4KPynETgrfh7y3A==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -11413,6 +12441,11 @@ } } }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -11572,6 +12605,14 @@ "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", "dev": true }, + "catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "requires": { + "lodash": "^4.17.15" + } + }, "chalk": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", @@ -11594,6 +12635,11 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -11682,7 +12728,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, "requires": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -11861,6 +12906,11 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -11878,8 +12928,7 @@ "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "deepmerge": { "version": "4.2.2", @@ -11928,9 +12977,9 @@ "dev": true }, "dezalgo": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.3.tgz", - "integrity": "sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, "requires": { "asap": "^2.0.0", @@ -11977,6 +13026,29 @@ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-8.0.3.tgz", "integrity": "sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==" }, + "duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -12016,7 +13088,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -12031,6 +13102,11 @@ "tapable": "^2.2.0" } }, + "entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -12049,8 +13125,7 @@ "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, "escape-html": { "version": "1.0.3", @@ -12063,6 +13138,61 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==" + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "requires": { + "prelude-ls": "~1.1.2" + } + } + } + }, "eslint": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.22.0.tgz", @@ -12211,14 +13341,12 @@ "eslint-visitor-keys": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==" }, "espree": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz", "integrity": "sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==", - "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", @@ -12228,8 +13356,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.4.0", @@ -12268,20 +13395,28 @@ "estraverse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "eventemitter2": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.6.tgz", + "integrity": "sha512-OHqo4wbHX5VbvlbB6o6eDwhYmiTjrpWACjF8Pmof/GTD6rdBNdZFNck3xlhqOiQFGCOoq3uzHvA0cQpFHIGVAQ==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -12382,6 +13517,11 @@ } } }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -12427,14 +13567,18 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "fast-text-encoding": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", + "integrity": "sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==" + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -12591,22 +13735,25 @@ } }, "formidable": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz", - "integrity": "sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", + "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", "dev": true, "requires": { - "dezalgo": "1.0.3", - "hexoid": "1.0.0", - "once": "1.4.0", - "qs": "6.9.3" + "dezalgo": "^1.0.4", + "hexoid": "^1.0.0", + "once": "^1.4.0", + "qs": "^6.11.0" }, "dependencies": { "qs": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.3.tgz", - "integrity": "sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==", - "dev": true + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } } } }, @@ -12684,6 +13831,26 @@ "wide-align": "^1.1.2" } }, + "gaxios": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.0.2.tgz", + "integrity": "sha512-TjtV2AJOZoMQqRYoy5eM8cCQogYwazWNYLQ72QB0kwa6vHHruYkGmhhyrlzbmgNHK1dNnuP2WSH81urfzyN2Og==", + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.7" + } + }, + "gcp-metadata": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.0.1.tgz", + "integrity": "sha512-jiRJ+Fk7e8FH68Z6TLaqwea307OktJpDjmYnU7/li6ziwvVvU2RlrCyQo5vkdeP94chm0kcSCOOszvmuaioq3g==", + "requires": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12693,8 +13860,7 @@ "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, "get-intrinsic": { "version": "1.1.3", @@ -12769,11 +13935,76 @@ "slash": "^3.0.0" } }, + "google-auth-library": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz", + "integrity": "sha512-1M0NG5VDIvJZEnstHbRdckLZESoJwguinwN8Dhae0j2ZKIQFIV63zxm6Fo6nM4xkgqUr2bbMtV5Dgo+Hy6oo0Q==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^5.0.0", + "gcp-metadata": "^5.0.0", + "gtoken": "^6.1.0", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "dependencies": { + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + } + } + }, + "google-gax": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-3.5.2.tgz", + "integrity": "sha512-AyP53w0gHcWlzxm+jSgqCR3Xu4Ld7EpSjhtNBnNhzwwWaIUyphH9kBGNIEH+i4UGkTUXOY29K/Re8EiAvkBRGw==", + "requires": { + "@grpc/grpc-js": "~1.7.0", + "@grpc/proto-loader": "^0.7.0", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "fast-text-encoding": "^1.0.3", + "google-auth-library": "^8.0.2", + "is-stream-ended": "^0.1.4", + "node-fetch": "^2.6.1", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^1.0.0", + "protobufjs": "7.1.2", + "protobufjs-cli": "1.0.2", + "retry-request": "^5.0.0" + } + }, + "google-p12-pem": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-4.0.1.tgz", + "integrity": "sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==", + "requires": { + "node-forge": "^1.3.1" + } + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "grapheme-splitter": { "version": "1.0.4", @@ -12781,6 +14012,37 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "gtoken": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-6.1.2.tgz", + "integrity": "sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==", + "requires": { + "gaxios": "^5.0.1", + "google-p12-pem": "^4.0.0", + "jws": "^4.0.0" + }, + "dependencies": { + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + } + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -12967,6 +14229,11 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-base64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-1.1.0.tgz", + "integrity": "sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==" + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -12976,6 +14243,11 @@ "binary-extensions": "^2.0.0" } }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-core-module": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", @@ -13026,8 +14298,12 @@ "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" }, "is-unicode-supported": { "version": "0.1.0", @@ -13740,12 +15016,62 @@ "argparse": "^2.0.1" } }, + "js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "requires": { + "xmlcreate": "^2.0.4" + } + }, + "jsdoc": { + "version": "3.6.11", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.11.tgz", + "integrity": "sha512-8UCU0TYeIYD9KeLzEcAu2q8N/mx9O3phAGl32nmHlE0LpaJL71mMkP4d+QE5zWfNt50qheHtOZ0qoxVrsX5TUg==", + "requires": { + "@babel/parser": "^7.9.4", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "taffydb": "2.6.2", + "underscore": "~1.13.2" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13829,6 +15155,14 @@ "safe-buffer": "^5.0.1" } }, + "klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "requires": { + "graceful-fs": "^4.1.9" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -13862,6 +15196,14 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "requires": { + "uc.micro": "^1.0.1" + } + }, "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -13882,6 +15224,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, "lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -13956,6 +15303,11 @@ } } }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -14009,6 +15361,44 @@ "tmpl": "1.0.5" } }, + "markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "requires": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, + "markdown-it-anchor": { + "version": "8.6.5", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.5.tgz", + "integrity": "sha512-PI1qEHHkTNWT+X6Ip9w+paonfIQ+QZP9sCeMYi47oqhH+EsW8CrJ8J7CzV19QVOj6il8ATGbK2nTECj22ZHGvQ==", + "requires": {} + }, + "marked": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.2.3.tgz", + "integrity": "sha512-slWRdJkbTZ+PjkyJnE30Uid64eHwbwa1Q25INCAYfZlK4o6ylagBy/Le9eWntqJFoFT93ikUKMv47GZ4gTwHkw==" + }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -14232,6 +15622,11 @@ "whatwg-url": "^5.0.0" } }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14626,6 +16021,109 @@ "sisteransi": "^1.0.5" } }, + "proto3-json-serializer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-1.1.0.tgz", + "integrity": "sha512-SjXwUWe/vANGs/mJJTbw5++7U67nwsymg7qsoPtw6GiXqw3kUy8ByojrlEdVE2efxAdKreX8WkDafxvYW95ZQg==", + "requires": { + "protobufjs": "^7.0.0" + } + }, + "protobufjs": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.2.tgz", + "integrity": "sha512-4ZPTPkXCdel3+L81yw3dG6+Kq3umdWKh7Dc7GW/CpNk4SX3hK58iPCWeCyhVTDrbkNeKrYNZ7EojM5WDaEWTLQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "dependencies": { + "long": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.1.tgz", + "integrity": "sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A==" + } + } + }, + "protobufjs-cli": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.0.2.tgz", + "integrity": "sha512-cz9Pq9p/Zs7okc6avH20W7QuyjTclwJPgqXG11jNaulfS3nbVisID8rC+prfgq0gbZE0w9LBFd1OKFF03kgFzg==", + "requires": { + "chalk": "^4.0.0", + "escodegen": "^1.13.0", + "espree": "^9.0.0", + "estraverse": "^5.1.0", + "glob": "^8.0.0", + "jsdoc": "^3.6.3", + "minimist": "^1.2.0", + "semver": "^7.1.2", + "tmp": "^0.2.1", + "uglify-js": "^3.7.7" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "glob": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz", + "integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.1.tgz", + "integrity": "sha512-362NP+zlprccbEt/SkxKfRMHnNY85V74mVnpUpNyr3F35covl09Kec7/sEFLt3RA4oXmewtoaanoIf67SE5Y5g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "requires": { + "rimraf": "^3.0.0" + } + } + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -14651,6 +16149,18 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "pusher": { + "version": "5.1.1-beta", + "resolved": "https://registry.npmjs.org/pusher/-/pusher-5.1.1-beta.tgz", + "integrity": "sha512-kGzseVc0MM5kJ6d5TFbtZXeBqDI1enkDq5QjfviEly9/57RSCiY/965ve6IJ5NbMp7T8ZPCqg82FIAkJ+zFxNg==", + "requires": { + "abort-controller": "^3.0.0", + "is-base64": "^1.1.0", + "node-fetch": "^2.6.1", + "tweetnacl": "^1.0.0", + "tweetnacl-util": "^0.15.0" + } + }, "qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -14754,8 +16264,7 @@ "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, "require-from-string": { "version": "2.0.2", @@ -14763,6 +16272,14 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true }, + "requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "requires": { + "lodash": "^4.17.21" + } + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -14813,6 +16330,15 @@ "signal-exit": "^3.0.2" } }, + "retry-request": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-5.0.2.tgz", + "integrity": "sha512-wfI3pk7EE80lCIXprqh7ym48IHYdwmAAzESdbU8Q9l7pnRCk9LEhpbOTNKjz6FARLm/Bl5m+4F0ABxOkYUujSQ==", + "requires": { + "debug": "^4.1.1", + "extend": "^3.0.2" + } + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -15089,6 +16615,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, "streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -15152,8 +16683,7 @@ "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, "superagent": { "version": "8.0.0", @@ -15238,6 +16768,11 @@ "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", "dev": true }, + "taffydb": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz", + "integrity": "sha512-y3JaeRSplks6NYQuCOj3ZFMO3j60rTwbuKCvZxsAraGYH2epusatvZ0baZYA01WsGqJBq/Dl6vOrMUJqyMj8kA==" + }, "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -15523,6 +17058,16 @@ } } }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, + "tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -15564,6 +17109,21 @@ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", "dev": true }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, + "uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==" + }, + "underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -15791,14 +17351,12 @@ "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -15820,6 +17378,11 @@ "signal-exit": "^3.0.7" } }, + "xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -15828,8 +17391,7 @@ "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" }, "yallist": { "version": "4.0.0", diff --git a/package.json b/package.json index 2bb10b1..f92313d 100644 --- a/package.json +++ b/package.json @@ -21,14 +21,17 @@ "test:e2e": "jest --config ./test/jest-e2e.json --detectOpenHandles" }, "dependencies": { + "@google-cloud/recaptcha-enterprise": "^3.1.1", "@nestjs/axios": "^0.1.0", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", + "@nestjs/event-emitter": "^1.3.1", "@nestjs/jwt": "^9.0.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/swagger": "^6.1.2", + "@nestjs/throttler": "^3.1.0", "@prisma/client": "^4.4.0", "bcrypt": "^5.1.0", "cache-manager": "^4.0.0", @@ -38,6 +41,7 @@ "passport": "^0.6.0", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", + "pusher": "^5.1.1-beta", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0", diff --git a/src/_domain/_domain.module.ts b/src/_domain/_domain.module.ts new file mode 100644 index 0000000..9b95f29 --- /dev/null +++ b/src/_domain/_domain.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { PusherModule } from "../pusher/pusher.module"; +import { CommentEventsListener } from "./listeners/commentEvents.listener"; +import { PostEventsListener } from "./listeners/postEvents.listener"; + +@Module({ + imports: [PusherModule], + providers: [CommentEventsListener, PostEventsListener], +}) +export class DomainModule {} diff --git a/src/_domain/eventTypes.ts b/src/_domain/eventTypes.ts new file mode 100644 index 0000000..c15c802 --- /dev/null +++ b/src/_domain/eventTypes.ts @@ -0,0 +1,13 @@ +export enum EventTypes { + NewCommentOnPost = "new-comment-on-post", + NewCommentOnComment = "new-comment-on-comment", + CommentGotUpVote = "comment-got-up-vote", + CommentGotDownVote = "comment-got-down-vote", + CommentGotRestricted = "comment-got-restricted", + CommentGotApprovedByModerator = "comment-got-approved-by-moderator", + CommentGotPinnedByAuthor = "comment-got-pinned-by-author", + PostGotUpVote = "post-got-up-vote", + PostGotDownVote = "post-got-down-vote", + PostGotRestricted = "post-got-restricted", + PostGotApprovedByModerator = "post-got-approved-by-moderator", +} diff --git a/src/_domain/injectableTokens.ts b/src/_domain/injectableTokens.ts index 3d31229..1fc22c0 100644 --- a/src/_domain/injectableTokens.ts +++ b/src/_domain/injectableTokens.ts @@ -1,3 +1,5 @@ +import { INotificationMessageMakerService } from "../pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface"; + const injectableTokens = { // Database Context IDatabaseContext: Symbol.for("IDatabaseContext"), @@ -10,6 +12,8 @@ const injectableTokens = { ISexualityRepository: Symbol("ISexualityRepository"), IOpennessRepository: Symbol("IOpennessRepository"), IGenderRepository: Symbol("IGenderRepository"), + IProfileSetupService: Symbol("IProfileSetupService"), + IUserHistoryService: Symbol("IUserHistoryService"), // Posts Module IPostsRepository: Symbol("IPostsRepository"), @@ -17,15 +21,27 @@ const injectableTokens = { IPostTagsRepository: Symbol("IPostTagsRepository"), IPostsService: Symbol("IPostsService"), IPostAwardRepository: Symbol("IPostAwardRepository"), + IPostsReportService: Symbol("IPostsReportService"), // Comments Module ICommentsRepository: Symbol("ICommentsRepository"), ICommentsService: Symbol("ICommentsService"), + ICommentsReportService: Symbol("ICommentsReportService"), // Neo4j Module INeo4jService: Symbol("INeo4jService"), // Moderation Module IAutoModerationService: Symbol("IAutoModerationService"), + IModeratorActionsService: Symbol("IModeratorActionsService"), + + // Google Cloud reCAPTCHA Enterprise Module + IGoogleCloudRecaptchaEnterpriseService: Symbol("IGoogleCloudRecaptchaEnterpriseService"), + + // Pusher + IPusherService: Symbol("IPusherService"), + IPusherUserPoolService: Symbol("IPusherUserPoolService"), + INotificationStashPoolService: Symbol("INotificationStashPoolService"), + INotificationMessageMakerService: Symbol("INotificationMessageMakerService"), }; export { injectableTokens as _$ }; diff --git a/src/_domain/listeners/commentEvents.listener.ts b/src/_domain/listeners/commentEvents.listener.ts new file mode 100644 index 0000000..cfb3b80 --- /dev/null +++ b/src/_domain/listeners/commentEvents.listener.ts @@ -0,0 +1,219 @@ +import { Inject, Injectable, Logger, Scope } from "@nestjs/common"; +import { IPusherService } from "../../pusher/services/pusher/pusher.service.interface"; +import { _$ } from "../injectableTokens"; +import { OnEvent } from "@nestjs/event-emitter"; +import { ChannelTypesEnum, PusherEvents } from "../../pusher/pusher.types"; +import { + CommentGotApprovedByModeratorEvent, + CommentGotRestrictedEvent, +} from "../../moderation/events"; +import { + CommentGotPinnedByAuthorEvent, + CommentGotVoteEvent, + NewCommentEvent, +} from "../../comments/events"; +import { INotificationMessageMakerService } from "../../pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface"; +import { EventTypes } from "../eventTypes"; +import { INotificationStashPoolService } from "../../pusher/services/notificationStashPool/notificationStashPool.service.interface"; +import { generateUUID } from "../utils"; + +@Injectable({ scope: Scope.DEFAULT }) +export class CommentEventsListener { + private readonly _logger = new Logger(CommentEventsListener.name); + + private readonly _pusherService: IPusherService; + private readonly _notificationMessageMakerService: INotificationMessageMakerService; + private readonly _notificationStashPoolService: INotificationStashPoolService; + + constructor( + @Inject(_$.IPusherService) pusherService: IPusherService, + @Inject(_$.INotificationMessageMakerService) + notificationMessageMakerService: INotificationMessageMakerService, + @Inject(_$.INotificationStashPoolService) + notificationStashPoolService: INotificationStashPoolService + ) { + this._pusherService = pusherService; + this._notificationMessageMakerService = notificationMessageMakerService; + this._notificationStashPoolService = notificationStashPoolService; + } + + @OnEvent(EventTypes.CommentGotUpVote, { async: true }) + public async handleCommentGotUpVote(event: CommentGotVoteEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForCommentGotUpVote({ + username: event.username, + postId: event.postId, + commentId: event.commentId, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.CommentGotUpVote, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.CommentGotDownVote, { async: true }) + public async handleCommentGotDownVote(event: CommentGotVoteEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForCommentGotDownVote({ + username: event.username, + postId: event.postId, + commentId: event.commentId, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.CommentGotDownVote, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.CommentGotRestricted, { async: true }) + public async handleCommentGotRestricted(event: CommentGotRestrictedEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForCommentGotRestricted({ + commentContent: event.commentContent, + reason: event.reason, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.CommentGotRestricted, + event.subscriberUserId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.CommentGotPinnedByAuthor, { async: true }) + public async handleCommentGotPinnedByAuthor( + event: CommentGotPinnedByAuthorEvent + ): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForCommentGotPinnedByAuthor({ + commentId: event.commentId, + postId: event.postId, + commentContent: event.commentContent, + username: event.username, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.CommentGotPinnedByAuthor, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.CommentGotApprovedByModerator, { async: true }) + public async handleCommentGotApprovedByModerator( + event: CommentGotApprovedByModeratorEvent + ): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForCommentGotApprovedByModerator({ + commentId: event.commentId, + postId: event.postId, + username: event.username, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.CommentGotApprovedByModerator, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.NewCommentOnComment, { async: true }) + public async handleNewCommentOnComment(event: NewCommentEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForNewCommentOnComment({ + postId: event.postId, + commentId: event.commentId, + username: event.username, + commentContent: event.commentContent, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.NewCommentOnComment, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.NewCommentOnPost, { async: true }) + public async handleNewCommentOnPost(event: NewCommentEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForNewCommentOnPost({ + postId: event.postId, + commentId: event.commentId, + username: event.username, + postTypeName: event.postTypeName, + commentContent: event.commentContent, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.NewCommentOnPost, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + private async stashAndPushNotification( + stashToken: UUID, + evenType: EventTypes, + subscriberId: UUID, + username: string, + avatar: string, + message: string + ): Promise { + const stashPoolItem = await this._notificationStashPoolService.stashNotification( + stashToken, + subscriberId, + message, + avatar, + username + ); + + this._pusherService + .triggerUser( + ChannelTypesEnum.IGAQ_Notification, + PusherEvents.UserReceivesNotification, + subscriberId, + { + subscriberId, + username: username, + avatar: avatar, + composedMessage: message, + stashToken: stashToken, + } + ) + .then(() => this._logger.verbose(`Event ${evenType} got pushed to ${username}`)) + .catch(e => this._logger.error(`Event ${evenType} ERRORED: `, e)); + } +} diff --git a/src/_domain/listeners/postEvents.listener.ts b/src/_domain/listeners/postEvents.listener.ts new file mode 100644 index 0000000..2064d25 --- /dev/null +++ b/src/_domain/listeners/postEvents.listener.ts @@ -0,0 +1,144 @@ +import { Inject, Injectable, Logger, Scope } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { ChannelTypesEnum, PusherEvents } from "../../pusher/pusher.types"; +import { PostGotVoteEvent } from "../../posts/events"; +import { PostGotApprovedByModeratorEvent, PostGotRestrictedEvent } from "../../moderation/events"; +import { _$ } from "../injectableTokens"; +import { IPusherService } from "../../pusher/services/pusher/pusher.service.interface"; +import { INotificationMessageMakerService } from "../../pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface"; +import { EventTypes } from "../eventTypes"; +import { INotificationStashPoolService } from "../../pusher/services/notificationStashPool/notificationStashPool.service.interface"; +import { generateUUID } from "../utils"; + +@Injectable({ scope: Scope.DEFAULT }) +export class PostEventsListener { + private readonly _logger = new Logger(PostEventsListener.name); + + private readonly _pusherService: IPusherService; + private readonly _notificationMessageMakerService: INotificationMessageMakerService; + private readonly _notificationStashPoolService: INotificationStashPoolService; + + constructor( + @Inject(_$.IPusherService) pusherService: IPusherService, + @Inject(_$.INotificationMessageMakerService) + notificationMessageMakerService: INotificationMessageMakerService, + @Inject(_$.INotificationStashPoolService) + notificationStashPoolService: INotificationStashPoolService + ) { + this._pusherService = pusherService; + this._notificationMessageMakerService = notificationMessageMakerService; + this._notificationStashPoolService = notificationStashPoolService; + } + + @OnEvent(EventTypes.PostGotUpVote, { async: true }) + public async handlePostGotUpVote(event: PostGotVoteEvent): Promise { + console.log("event listener emitted"); + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForPostGotUpVote({ + username: event.username, + postId: event.postId, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.PostGotUpVote, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.PostGotDownVote, { async: true }) + public async handlePostGotDownVote(event: PostGotVoteEvent): Promise { + console.log("event listener emitted"); + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + + const message = this._notificationMessageMakerService.makeForPostGotDownVote({ + username: event.username, + postId: event.postId, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.PostGotDownVote, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.PostGotApprovedByModerator, { async: true }) + public async handlePostGotApprovedByModerator( + event: PostGotApprovedByModeratorEvent + ): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForPostGotApprovedByModerator({ + username: event.username, + postId: event.postId, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.PostGotApprovedByModerator, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + @OnEvent(EventTypes.PostGotRestricted, { async: true }) + public async handlePostGotRestricted(event: PostGotRestrictedEvent): Promise { + const stashToken = generateUUID(); + this._notificationMessageMakerService.stashToken = stashToken; + const message = this._notificationMessageMakerService.makeForPostGotRestricted({ + postTitle: event.postTitle, + reason: event.reason, + }); + + await this.stashAndPushNotification( + stashToken, + EventTypes.PostGotRestricted, + event.subscriberId, + event.username, + event.avatar, + message + ); + } + + private async stashAndPushNotification( + stashToken: UUID, + evenType: EventTypes, + subscriberId: UUID, + username: string, + avatar: string, + message: string + ): Promise { + const stashPoolItem = await this._notificationStashPoolService.stashNotification( + stashToken, + subscriberId, + message + ); + + this._pusherService + .triggerUser( + ChannelTypesEnum.IGAQ_Notification, + PusherEvents.UserReceivesNotification, + subscriberId, + { + subscriberId, + username: username, + avatar: avatar, + composedMessage: message, + stashToken: stashToken, + } + ) + .then(() => this._logger.verbose(`Event ${evenType} got pushed to ${username}`)) + .catch(e => this._logger.error(`Event ${evenType} ERRORED: `, e)); + } +} diff --git a/src/_domain/models/enums/index.ts b/src/_domain/models/enums/index.ts new file mode 100644 index 0000000..f7bd450 --- /dev/null +++ b/src/_domain/models/enums/index.ts @@ -0,0 +1 @@ +export { VoteType } from "./voteType.enum"; diff --git a/src/_domain/models/enums/voteType.enum.ts b/src/_domain/models/enums/voteType.enum.ts new file mode 100644 index 0000000..4134f29 --- /dev/null +++ b/src/_domain/models/enums/voteType.enum.ts @@ -0,0 +1,4 @@ +export enum VoteType { + UPVOTES = "UPVOTES", + DOWN_VOTES = "DOWN_VOTES", +} diff --git a/src/comments/models/toSelf/deleted.props.ts b/src/_domain/models/toSelf/deleted.props.ts similarity index 59% rename from src/comments/models/toSelf/deleted.props.ts rename to src/_domain/models/toSelf/deleted.props.ts index f37f2d6..fcfed40 100644 --- a/src/comments/models/toSelf/deleted.props.ts +++ b/src/_domain/models/toSelf/deleted.props.ts @@ -1,12 +1,15 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber, IsString, IsUUID } from "class-validator"; export class DeletedProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() deletedAt: number; - @ApiProperty({ type: String }) - deletedByUserId: string; + @IsUUID() + moderatorId: UUID; + + @IsString() + reason: string; constructor(partial?: Partial) { Object.assign(this, partial); diff --git a/src/_domain/models/toSelf/index.ts b/src/_domain/models/toSelf/index.ts index 1ce039d..ae3baa8 100644 --- a/src/_domain/models/toSelf/index.ts +++ b/src/_domain/models/toSelf/index.ts @@ -1,5 +1,7 @@ export { RestrictedProps } from "./restricted.props"; +export { DeletedProps } from "./deleted.props"; export enum _ToSelfRelTypes { RESTRICTED = "RESTRICTED", + DELETED = "DELETED", } diff --git a/src/_domain/models/toSelf/restricted.props.ts b/src/_domain/models/toSelf/restricted.props.ts index fc751db..8373768 100644 --- a/src/_domain/models/toSelf/restricted.props.ts +++ b/src/_domain/models/toSelf/restricted.props.ts @@ -6,7 +6,7 @@ export class RestrictedProps implements RelationshipProps { restrictedAt: number; @ApiProperty({ type: String }) - moderatorId: string; + moderatorId: UUID; @ApiProperty({ type: String }) reason: string; diff --git a/src/_domain/utils.ts b/src/_domain/utils.ts new file mode 100644 index 0000000..30c9bb6 --- /dev/null +++ b/src/_domain/utils.ts @@ -0,0 +1,15 @@ +import { v4 as uuidv4 } from "uuid"; + +export function makeStringId(length) { + let result = ""; + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +export function generateUUID(): UUID { + return uuidv4(); +} diff --git a/src/app.module.ts b/src/app.module.ts index 66995c6..470fc5a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,10 +10,22 @@ import { PostsModule } from "./posts/posts.module"; import { UsersModule } from "./users/users.module"; import { AppLoggerMiddleware } from "./_domain/middlewares/appLogger.middleware"; import { neo4jCredentials } from "./_domain/constants"; -import { ModerationModule } from './moderation/moderation.module'; +import { ModerationModule } from "./moderation/moderation.module"; +import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { APP_GUARD } from "@nestjs/core"; +import { GoogleCloudRecaptchaEnterpriseModule } from "./google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module"; +import { PusherModule } from "./pusher/pusher.module"; +import { EventEmitterModule } from "@nestjs/event-emitter"; +import { DomainModule } from "./_domain/_domain.module"; @Module({ imports: [ + EventEmitterModule.forRoot({}), + DomainModule, + ThrottlerModule.forRoot({ + ttl: 69, + limit: 42, + }), Neo4jModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -38,6 +50,14 @@ import { ModerationModule } from './moderation/moderation.module'; CommentsModule, DatabaseAccessLayerModule, ModerationModule, + GoogleCloudRecaptchaEnterpriseModule, + PusherModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], }) export class AppModule { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index bd32ffb..d97a726 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -7,9 +7,15 @@ import { AuthService } from "./services/auth.service"; import { JwtStrategy } from "./strategy"; import { _$ } from "../_domain/injectableTokens"; import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module"; +import { GoogleCloudRecaptchaEnterpriseModule } from "../google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module"; @Module({ - imports: [forwardRef(() => DatabaseAccessLayerModule), UsersModule, JwtModule.register({})], + imports: [ + forwardRef(() => DatabaseAccessLayerModule), + UsersModule, + JwtModule.register({}), + GoogleCloudRecaptchaEnterpriseModule, + ], controllers: [AuthController], providers: [ { diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index ce23320..7aa1282 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -1,11 +1,20 @@ import { Body, Controller, Inject, Post, UseGuards } from "@nestjs/common"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; -import { SignInPayloadDto, SignTokenDto, SignUpPayloadDto } from "../dtos"; +import { + SignInPayloadDto, + SignTokenDto, + SignUpPayloadDto, + ChangePasswordAdminDto, + ChangePasswordUserDto, +} from "../dtos"; import { IAuthService } from "../services/auth.service.interface"; import { AuthGuard } from "@nestjs/passport"; import { _$ } from "../../_domain/injectableTokens"; import { AuthedUser } from "../decorators/authedUser.param.decorator"; -import { User } from "../../users/models"; +import { Role, User } from "../../users/models"; +import { Roles } from "../decorators/roles.decorator"; +import { RolesGuard } from "../guards/roles.guard"; +import { CaptchaGuard } from "../../google-cloud-recaptcha-enterprise/captcha.guard"; @ApiTags("authentication") @ApiBearerAuth() @@ -14,11 +23,13 @@ export class AuthController { constructor(@Inject(_$.IAuthService) private _authService: IAuthService) {} @Post("signup") + @UseGuards(CaptchaGuard) public signup(@Body() signUpPayloadDto: SignUpPayloadDto): Promise { return this._authService.signup(signUpPayloadDto); } @Post("signin") + @UseGuards(CaptchaGuard) public signin(@Body() signInPayloadDto: SignInPayloadDto): Promise { return this._authService.signIn(signInPayloadDto); } @@ -28,4 +39,21 @@ export class AuthController { public async authenticate(@AuthedUser() user: User) { return await user.toJSON(); } + + @Post("change-password") + @UseGuards(AuthGuard("jwt")) + public async changePasswordUser( + @AuthedUser() user: User, + @Body() payload: ChangePasswordUserDto + ): Promise { + payload.user = user; + await this._authService.changePasswordUser(payload); + } + + @Post("change-password-admin") + @Roles(Role.ADMIN) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async changePasswordAdmin(@Body() payload: ChangePasswordAdminDto): Promise { + await this._authService.changePasswordAdmin(payload); + } } diff --git a/src/auth/dtos/changePasswordAdmin.dto.ts b/src/auth/dtos/changePasswordAdmin.dto.ts new file mode 100644 index 0000000..653b89f --- /dev/null +++ b/src/auth/dtos/changePasswordAdmin.dto.ts @@ -0,0 +1,16 @@ +import { IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +export class ChangePasswordAdminDto { + @ApiProperty({ type: String }) + @IsString() + newPassword: string; + + @ApiProperty({ type: String }) + @IsString() + username: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/auth/dtos/changePasswordUser.dto.ts b/src/auth/dtos/changePasswordUser.dto.ts new file mode 100644 index 0000000..c55b654 --- /dev/null +++ b/src/auth/dtos/changePasswordUser.dto.ts @@ -0,0 +1,19 @@ +import { IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { User } from "../../users/models"; + +export class ChangePasswordUserDto { + @ApiProperty({ type: String }) + @IsString() + previousPassword: string; + + @ApiProperty({ type: String }) + @IsString() + newPassword: string; + + user: User; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/auth/dtos/index.ts b/src/auth/dtos/index.ts index aa33ee7..92dae31 100644 --- a/src/auth/dtos/index.ts +++ b/src/auth/dtos/index.ts @@ -1,3 +1,6 @@ export { SignTokenDto } from "./signToken.dto"; export { SignInPayloadDto } from "./signInPayload.dto"; export { SignUpPayloadDto } from "./signUpPayload.dto"; +export { JwtTokenPayloadDto } from "./jwtTokenPayload.dto"; +export { ChangePasswordAdminDto } from "./changePasswordAdmin.dto"; +export { ChangePasswordUserDto } from "./changePasswordUser.dto"; diff --git a/src/auth/dtos/jwtTokenPayload.dto.ts b/src/auth/dtos/jwtTokenPayload.dto.ts new file mode 100644 index 0000000..61b7a53 --- /dev/null +++ b/src/auth/dtos/jwtTokenPayload.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsUUID } from "class-validator"; + +export class JwtTokenPayloadDto { + @IsUUID() + sub: UUID; + + @IsString() + username: string; + + constructor(partials?: Partial) { + Object.assign(this, partials); + } +} diff --git a/src/auth/guards/optionalJwtAuth.guard.ts b/src/auth/guards/optionalJwtAuth.guard.ts new file mode 100644 index 0000000..6caa130 --- /dev/null +++ b/src/auth/guards/optionalJwtAuth.guard.ts @@ -0,0 +1,8 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class OptionalJwtAuthGuard extends AuthGuard("jwt") { + // Override handleRequest so it never throws an error + handleRequest(err, user) { + return user; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index 96a838e..df40833 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { Observable } from "rxjs"; import { Reflector } from "@nestjs/core"; import { ROLES_KEY } from "../decorators/roles.decorator"; -import { Role } from "../../users/models"; +import { Role, User } from "../../users/models"; @Injectable() export class RolesGuard implements CanActivate { @@ -18,11 +18,18 @@ export class RolesGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); + const user: User = request.user; + console.log(user); if (!user) { return false; } + // If the user is an admin, they can do anything. + if (user.roles?.includes(Role.ADMIN)) { + return true; + } + return requiredRoles.some(role => user.roles?.includes(role)); } } diff --git a/src/auth/services/auth.service.interface.ts b/src/auth/services/auth.service.interface.ts index 38e1e62..2401a78 100644 --- a/src/auth/services/auth.service.interface.ts +++ b/src/auth/services/auth.service.interface.ts @@ -1,7 +1,17 @@ -import { SignInPayloadDto, SignTokenDto, SignUpPayloadDto } from "../dtos"; +import { + SignInPayloadDto, + SignTokenDto, + SignUpPayloadDto, + ChangePasswordAdminDto, + ChangePasswordUserDto, +} from "../dtos"; export interface IAuthService { signup(signUpPayloadDto: SignUpPayloadDto): Promise; signIn(signInPayloadDto: SignInPayloadDto): Promise; + + changePasswordUser(payload: ChangePasswordUserDto): Promise; + + changePasswordAdmin(payload: ChangePasswordAdminDto): Promise; } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index c6e13e4..39acce3 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,15 +1,24 @@ -import { HttpException, Inject, Injectable } from "@nestjs/common"; +import { HttpException, Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import * as bcrypt from "bcrypt"; import { IUsersRepository } from "../../users/repositories/users/users.repository.interface"; -import { SignInPayloadDto, SignTokenDto, SignUpPayloadDto } from "../dtos"; +import { + ChangePasswordAdminDto, + SignInPayloadDto, + SignTokenDto, + SignUpPayloadDto, + JwtTokenPayloadDto, + ChangePasswordUserDto, +} from "../dtos"; import { IAuthService } from "./auth.service.interface"; import { User } from "../../users/models"; import { _$ } from "../../_domain/injectableTokens"; @Injectable({}) export class AuthService implements IAuthService { + private readonly _logger: Logger = new Logger(AuthService.name); + constructor( @Inject(_$.IUsersRepository) private _usersRepository: IUsersRepository, private _jwtService: JwtService, @@ -17,8 +26,7 @@ export class AuthService implements IAuthService { ) {} public async signup(signUpPayloadDto: SignUpPayloadDto): Promise { - const salt = await bcrypt.genSalt(10); - const hash = await bcrypt.hash(signUpPayloadDto.password, salt); + const hash = await this.makePasswordHash(signUpPayloadDto.password); const foundUser = await this._usersRepository.findUserByUsername(signUpPayloadDto.username); if (foundUser) { @@ -45,18 +53,19 @@ export class AuthService implements IAuthService { access_token: token, }); } catch (error) { - throw new Error(error); + this._logger.warn(error); + throw new HttpException("something wrong happened", 500); } } public async signIn(signInPayloadDto: SignInPayloadDto): Promise { const user = await this._usersRepository.findUserByUsername(signInPayloadDto.username); if (!user) { - throw new HttpException("User not found", 404); + throw new HttpException("Authentication failed.", 400); } - const isMatch = await bcrypt.compare(signInPayloadDto.password, user.passwordHash); + const isMatch = await this.verifyPassword(signInPayloadDto.password, user.passwordHash); if (!isMatch) { - throw new HttpException("Incorrect password", 400); + throw new HttpException("Authentication failed.", 400); } const token = await this.signToken(user); @@ -67,7 +76,7 @@ export class AuthService implements IAuthService { } private async signToken(user: User): Promise { - const payload = { + const payload: JwtTokenPayloadDto = { sub: user.userId, username: user.username, }; @@ -75,8 +84,42 @@ export class AuthService implements IAuthService { const secret = this._configService.get("JWT_SECRET") || "secret"; return await this._jwtService.signAsync(payload, { - expiresIn: "15m", + expiresIn: "6h", secret: secret, }); } + + public async changePasswordUser(payload: ChangePasswordUserDto): Promise { + const previousPasswordMatch = await this.verifyPassword( + payload.previousPassword, + payload.user.passwordHash + ); + if (!previousPasswordMatch) { + throw new HttpException("Previous password is incorrect", 400); + } + + payload.user.passwordHash = await this.makePasswordHash(payload.newPassword); + + await this._usersRepository.updateUser(payload.user); + } + + public async changePasswordAdmin(payload: ChangePasswordAdminDto): Promise { + const user = await this._usersRepository.findUserByUsername(payload.username); + if (!user) { + throw new HttpException("User not found", 404); + } + + user.passwordHash = await this.makePasswordHash(payload.newPassword); + + await this._usersRepository.updateUser(user); + } + + private async verifyPassword(password: string, hash: string): Promise { + return await bcrypt.compare(password, hash); + } + + private async makePasswordHash(rawPasswor: string): Promise { + const salt = await bcrypt.genSalt(10); + return await bcrypt.hash(rawPasswor, salt); + } } diff --git a/src/auth/strategy/jwt.strategy.ts b/src/auth/strategy/jwt.strategy.ts index 6fa4dbb..0cd1360 100644 --- a/src/auth/strategy/jwt.strategy.ts +++ b/src/auth/strategy/jwt.strategy.ts @@ -4,6 +4,7 @@ import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { IUsersRepository } from "../../users/repositories/users/users.repository.interface"; import { _$ } from "../../_domain/injectableTokens"; +import { JwtTokenPayloadDto } from "../dtos"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -17,7 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: any) { + async validate(payload: JwtTokenPayloadDto) { return await this._usersRepository.findUserByUsername(payload.username); } } diff --git a/src/comments/comments.module.ts b/src/comments/comments.module.ts index 2c594a2..f03864b 100644 --- a/src/comments/comments.module.ts +++ b/src/comments/comments.module.ts @@ -6,10 +6,19 @@ import { DatabaseAccessLayerModule } from "../database-access-layer/database-acc import { forwardRef, Module } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ModerationModule } from "../moderation/moderation.module"; +import { PostsModule } from "../posts/posts.module"; +import { CommentsReportService } from "./services/commentReport/commentsReport.service"; +import { GoogleCloudRecaptchaEnterpriseModule } from "../google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module"; @Module({ controllers: [CommentsController], - imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule, ModerationModule], + imports: [ + forwardRef(() => DatabaseAccessLayerModule), + HttpModule, + ModerationModule, + PostsModule, + GoogleCloudRecaptchaEnterpriseModule, + ], providers: [ { provide: _$.ICommentsService, @@ -19,6 +28,10 @@ import { ModerationModule } from "../moderation/moderation.module"; provide: _$.ICommentsRepository, useClass: CommentsRepository, }, + { + provide: _$.ICommentsReportService, + useClass: CommentsReportService, + }, ], exports: [ { @@ -29,6 +42,10 @@ import { ModerationModule } from "../moderation/moderation.module"; provide: _$.ICommentsRepository, useClass: CommentsRepository, }, + { + provide: _$.ICommentsReportService, + useClass: CommentsReportService, + }, ], }) export class CommentsModule {} diff --git a/src/comments/controllers/comments.controller.ts b/src/comments/controllers/comments.controller.ts index a4bad23..864782b 100644 --- a/src/comments/controllers/comments.controller.ts +++ b/src/comments/controllers/comments.controller.ts @@ -4,6 +4,7 @@ import { CacheTTL, ClassSerializerInterceptor, Controller, + Delete, Get, HttpException, Inject, @@ -15,11 +16,20 @@ import { } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { AuthedUser } from "../../auth/decorators/authedUser.param.decorator"; +import { Roles } from "../../auth/decorators/roles.decorator"; +import { OptionalJwtAuthGuard } from "../../auth/guards/optionalJwtAuth.guard"; +import { RolesGuard } from "../../auth/guards/roles.guard"; import { DatabaseContext } from "../../database-access-layer/databaseContext"; +import { ModerationPayloadDto } from "../../moderation/dtos"; +import { IModeratorActionsService } from "../../moderation/services/moderatorActions/moderatorActions.service.interface"; +import { Role, User } from "../../users/models"; import { _$ } from "../../_domain/injectableTokens"; +import { CommentCreationPayloadDto, ReportCommentPayloadDto, VoteCommentPayloadDto } from "../dtos"; import { Comment as CommentModel } from "../models"; -import { CommentCreationPayloadDto } from "../dtos"; import { ICommentsService } from "../services/comments/comments.service.interface"; +import { ICommentsReportService } from "../services/commentReport/commentsReport.service.interface"; +import { CaptchaGuard } from "../../google-cloud-recaptcha-enterprise/captcha.guard"; @ApiTags("comments") @Controller("comments") @@ -28,39 +38,110 @@ import { ICommentsService } from "../services/comments/comments.service.interfac export class CommentsController { private readonly _dbContext: DatabaseContext; private readonly _commentsService: ICommentsService; + private readonly _moderatorActionsService: IModeratorActionsService; + private readonly _commentsReportService: ICommentsReportService; constructor( @Inject(_$.IDatabaseContext) dbContext: DatabaseContext, - @Inject(_$.ICommentsService) commentsService: ICommentsService + @Inject(_$.ICommentsService) commentsService: ICommentsService, + @Inject(_$.IModeratorActionsService) moderatorActionsService: IModeratorActionsService, + @Inject(_$.ICommentsReportService) commentsReportService: ICommentsReportService ) { this._dbContext = dbContext; this._commentsService = commentsService; + this._moderatorActionsService = moderatorActionsService; + this._commentsReportService = commentsReportService; } @Get() - @CacheTTL(10) // idk how many to cache this needs to change + @CacheTTL(4) + @UseGuards(OptionalJwtAuthGuard) @UseInterceptors(CacheInterceptor) - public async index(): Promise { + public async index(@AuthedUser() user: User): Promise { const comments = await this._dbContext.Comments.findAll(); - const decoratedComments = comments.map(comment => comment.toJSON()); + const decoratedComments = comments.map(comment => + comment.toJSON({ authenticatedUserId: user?.userId ?? undefined }) + ); + return await Promise.all(decoratedComments); + } + + @Get(":commentId/nestedComments") + @UseGuards(OptionalJwtAuthGuard) + public async getNestedComments( + @AuthedUser() user: User, + @Param("commentId", new ParseUUIDPipe()) commentId: UUID + ): Promise { + const comments = await this._commentsService.findNestedCommentsByCommentId( + commentId, + 10, + 2, + 3 + ); + const decoratedComments = comments.map(comment => + comment.toJSONNested({ authenticatedUserId: user?.userId ?? undefined }) + ); return await Promise.all(decoratedComments); } @Get(":commentId") + @UseGuards(OptionalJwtAuthGuard) public async getCommentById( - @Param("commentId", new ParseUUIDPipe()) commentId: string + @AuthedUser() user: User, + @Param("commentId", new ParseUUIDPipe()) commentId: UUID ): Promise { const comment = await this._dbContext.Comments.findCommentById(commentId); if (comment === undefined) throw new HttpException("Comment not found", 404); - return await comment.toJSON(); + return await comment.toJSON({ authenticatedUserId: user?.userId ?? undefined }); + } + + @Post(":commentId/pin") + @UseGuards(AuthGuard("jwt")) + public async pinComment( + @Param("commentId", new ParseUUIDPipe()) commentId: UUID + ): Promise { + await this._commentsService.markAsPinned(commentId); + } + + @Post(":commentId/unpin") + @UseGuards(AuthGuard("jwt")) + public async unpinComment( + @Param("commentId", new ParseUUIDPipe()) commentId: UUID + ): Promise { + await this._commentsService.markAsUnpinned(commentId); } @Post("create") + @UseGuards(CaptchaGuard) @UseGuards(AuthGuard("jwt")) public async createComment( @Body() commentPayload: CommentCreationPayloadDto - ): Promise { + ): Promise { const comment = await this._commentsService.authorNewComment(commentPayload); return await comment.toJSON(); } + + @Delete("/") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async deleteComment( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderatorActionsService.deleteComment(moderationPayload); + } + + @Post("/vote") + @UseGuards(AuthGuard("jwt")) + public async voteComment(@Body() voteCommentPayload: VoteCommentPayloadDto): Promise { + await this._commentsService.voteComment(voteCommentPayload); + } + + @Post("/report") + @UseGuards(AuthGuard("jwt")) + public async reportComment( + @Body() reportCommentPayload: ReportCommentPayloadDto + ): Promise { + await this._commentsReportService.reportComment(reportCommentPayload); + } } diff --git a/src/comments/dtos/commentCreationPayload.dto.ts b/src/comments/dtos/commentCreationPayload.dto.ts index 35a3c29..742f5b8 100644 --- a/src/comments/dtos/commentCreationPayload.dto.ts +++ b/src/comments/dtos/commentCreationPayload.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, isNotEmpty, IsNotEmpty, IsString } from "class-validator"; +import { IsBoolean, IsNotEmpty, IsString } from "class-validator"; export class CommentCreationPayloadDto { @ApiProperty({ type: String, minLength: 5, maxLength: 2500 }) @@ -10,7 +10,7 @@ export class CommentCreationPayloadDto { @ApiProperty({ type: String, format: "uuid" }) @IsString() @IsNotEmpty() - parentId: string; + parentId: UUID; @ApiProperty({ type: Boolean }) @IsBoolean() diff --git a/src/comments/dtos/index.ts b/src/comments/dtos/index.ts index b34e73f..7f94b1b 100644 --- a/src/comments/dtos/index.ts +++ b/src/comments/dtos/index.ts @@ -1,4 +1,3 @@ export { CommentCreationPayloadDto } from "./commentCreationPayload.dto"; -export { HateSpeechRequestPayloadDto } from "./hateSpeechRequestPayload.dto"; -export { HateSpeechResponseDto } from "./hateSpeechResponse.dto"; -export { VoteCommentPayloadDto, VoteType } from "./voteCommentPayload.dto"; +export { VoteCommentPayloadDto } from "./voteCommentPayload.dto"; +export { ReportCommentPayloadDto } from "./reportCommentPayload.dto"; diff --git a/src/comments/dtos/reportCommentPayload.dto.ts b/src/comments/dtos/reportCommentPayload.dto.ts new file mode 100644 index 0000000..27c4b84 --- /dev/null +++ b/src/comments/dtos/reportCommentPayload.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsUUID, IsString } from "class-validator"; + +export class ReportCommentPayloadDto { + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + commentId: UUID; + + @ApiProperty({ type: String, minLength: 5, maxLength: 500 }) + @IsString() + reason: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/comments/dtos/voteCommentPayload.dto.ts b/src/comments/dtos/voteCommentPayload.dto.ts index 1d73fa1..694017e 100644 --- a/src/comments/dtos/voteCommentPayload.dto.ts +++ b/src/comments/dtos/voteCommentPayload.dto.ts @@ -1,15 +1,16 @@ import { ApiProperty } from "@nestjs/swagger"; - -export enum VoteType { - UPVOTES = "UPVOTES", - DOWN_VOTES = "DOWN_VOTES", -} +import { IsEnum, IsNotEmpty, IsUUID } from "class-validator"; +import { VoteType } from "../../_domain/models/enums"; export class VoteCommentPayloadDto { @ApiProperty({ type: String, format: "uuid" }) - commentId: string; + @IsNotEmpty() + @IsUUID() + commentId: UUID; - @ApiProperty({ type: VoteType }) + @ApiProperty({ enum: VoteType }) + @IsNotEmpty() + @IsEnum(VoteType) voteType: VoteType; constructor(partial?: Partial) { diff --git a/src/comments/events/commentGotPinnedByAuthor.event.ts b/src/comments/events/commentGotPinnedByAuthor.event.ts new file mode 100644 index 0000000..40fba9d --- /dev/null +++ b/src/comments/events/commentGotPinnedByAuthor.event.ts @@ -0,0 +1,17 @@ +export class CommentGotPinnedByAuthorEvent { + subscriberId: UUID; + + commentId: UUID; + + postId: UUID; + + commentContent: string; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/comments/events/commentGotVote.event.ts b/src/comments/events/commentGotVote.event.ts new file mode 100644 index 0000000..9028033 --- /dev/null +++ b/src/comments/events/commentGotVote.event.ts @@ -0,0 +1,15 @@ +export class CommentGotVoteEvent { + subscriberId: UUID; + + postId: UUID; + + commentId: UUID; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/comments/events/index.ts b/src/comments/events/index.ts new file mode 100644 index 0000000..5c90b9b --- /dev/null +++ b/src/comments/events/index.ts @@ -0,0 +1,3 @@ +export * from "./newComment.event"; +export * from "./commentGotVote.event"; +export * from "./commentGotPinnedByAuthor.event"; diff --git a/src/comments/events/newComment.event.ts b/src/comments/events/newComment.event.ts new file mode 100644 index 0000000..0d9bc91 --- /dev/null +++ b/src/comments/events/newComment.event.ts @@ -0,0 +1,19 @@ +export class NewCommentEvent { + subscriberId: UUID; + + postId: UUID; + + commentId: UUID; + + username: string; + + avatar: string; + + commentContent: string; + + postTypeName?: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/comments/models/comment.ts b/src/comments/models/comment.ts index da9142e..8a4fa7f 100644 --- a/src/comments/models/comment.ts +++ b/src/comments/models/comment.ts @@ -1,61 +1,84 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Exclude } from "class-transformer"; +import { Exclude, Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsEnum, + IsInstance, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; +import neo4j from "neo4j-driver"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; import { Model } from "../../neo4j/neo4j.helper.types"; import { Neo4jService } from "../../neo4j/services/neo4j.service"; +import { PostToCommentRelTypes } from "../../posts/models/toComment"; +import { PublicUserDto } from "../../users/dtos"; import { User } from "../../users/models"; import { AuthoredProps, UserToCommentRelTypes } from "../../users/models/toComment"; -import { RestrictedProps, _ToSelfRelTypes } from "../../_domain/models/toSelf"; -import { CommentToSelfRelTypes, DeletedProps } from "./toSelf"; -import { PublicUserDto } from "../../users/dtos"; +import { VoteType } from "../../_domain/models/enums"; +import { DeletedProps, RestrictedProps, _ToSelfRelTypes } from "../../_domain/models/toSelf"; +import { CommentToSelfRelTypes } from "./toSelf"; @Labels("Comment") export class Comment extends Model { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - commentId: string; + @IsUUID() + commentId: UUID; /** * The time the comment was created. Its value will be derived from the relationship * properties of (u:User)-[authored:AUTHORED]->(c:Comment) RETURN c, authored * where authored.createdAt is the value of this property. */ - @ApiProperty({ type: Number }) + @IsNumber() createdAt: number; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() commentContent: string; - @ApiProperty({ type: String, format: "uuid" }) - parentId: Nullable; + @IsUUID() + @IsOptional() + parentId: Nullable; - @ApiProperty({ type: Boolean }) + @IsBoolean() pinned: boolean; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() updatedAt: number; - @ApiProperty({ type: User }) + @IsInstance(User) authorUser: User | any; - @ApiProperty({ type: Boolean }) @NodeProperty() + @IsBoolean() pending: boolean; - @ApiProperty({ type: Number }) + @IsNumber() totalVotes: number; - @ApiProperty({ type: RestrictedProps }) + @IsNumber() + @IsOptional() + totalComments: number | undefined; + + @IsInstance(RestrictedProps) + @IsOptional() restrictedProps: Nullable = null; - @ApiProperty({ type: Comment }) + @IsArray({ each: true }) + @Type(() => RestrictedProps) childComments: Comment[]; - @ApiProperty({ type: Boolean }) + @IsEnum(VoteType) + @IsOptional() + userVote: Nullable | undefined = undefined; + @NodeProperty() @Exclude() + @IsBoolean() deletedProps: Nullable = null; constructor(partial?: Partial, neo4jService?: Neo4jService) { @@ -63,13 +86,16 @@ export class Comment extends Model { Object.assign(this, partial); } - public async toJSON() { + public async toJSON(props: ToJSONProps = {}) { if (this.neo4jService) { await Promise.all([ this.getRestricted(), + this.getDeletedProps(), this.getCreatedAt(), this.getTotalVotes(), this.getAuthorUser(), + this.getPinStatus(), + ...(props.authenticatedUserId ? [this.getUserVote(props.authenticatedUserId)] : []), ]); } @@ -79,6 +105,76 @@ export class Comment extends Model { return { ...this }; } + public async toJSONNested(props: ToJSONProps = {}) { + if (!this.childComments) { + return this.toJSON(props); + } + for (const i in this.childComments) { + this.childComments[i] = await this.childComments[i].toJSONNested(props); + } + return this.toJSON(props); + } + + public async getChildrenComments(limit = 0): Promise { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (c:Comment)-[:${ + CommentToSelfRelTypes.REPLIED + }]->(p:Comment) WHERE p.commentId = $parentId + RETURN c + ${limit > 0 ? `LIMIT $limit` : ""} + `, + { + parentId: this.commentId, + ...(limit > 0 ? { limit: neo4j.int(limit) } : {}), + } + ); + const records = queryResult.records; + if (records.length === 0) return []; + this.childComments = records.map( + record => new Comment(record.get("c").properties, this.neo4jService) + ); + return this.childComments; + } + + public async getPinStatus(): Promise { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (n)-[r:${PostToCommentRelTypes.PINNED_COMMENT}]->(c:Comment { commentId: $commentId }) + RETURN r, c + `, + { + commentId: this.commentId, + } + ); + + this.pinned = queryResult.records.length > 0; + return this.pinned; + } + + public async getUserVote(userId): Promise> { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToCommentRelTypes.UPVOTES}|${UserToCommentRelTypes.DOWN_VOTES}]->(c:Comment { commentId: $commentId }) + RETURN r + `, + { + userId, + commentId: this.commentId, + } + ); + + if (queryResult.records.length > 0) { + // user has already voted on this comment + const relType = queryResult.records[0].get("r").type as VoteType; + this.userVote = relType; + return relType; + } + + this.userVote = null; + return null; + } + public async getTotalVotes(): Promise { const queryResult = await this.neo4jService.tryReadAsync( ` @@ -137,7 +233,7 @@ export class Comment extends Model { public async getDeletedProps(): Promise { const queryResult = await this.neo4jService.tryReadAsync( ` - MATCH (c:Comment {commentId: $commentId})-[r:${CommentToSelfRelTypes.DELETED}]->(c) + MATCH (c:Comment {commentId: $commentId})-[r:${_ToSelfRelTypes.DELETED}]->(c) RETURN r `, { @@ -154,7 +250,7 @@ export class Comment extends Model { await this.neo4jService.tryWriteAsync( ` MATCH (c:Comment {commentId: $commentId}) - MERGE (c)-[r:${CommentToSelfRelTypes.DELETED}]->(c) + MERGE (c)-[r:${_ToSelfRelTypes.DELETED}]->(c) SET r = $deletedProps `, { @@ -181,3 +277,7 @@ export class Comment extends Model { return result; } } + +interface ToJSONProps { + authenticatedUserId?: string; +} diff --git a/src/comments/models/toSelf/index.ts b/src/comments/models/toSelf/index.ts index 7c0bd4a..452ad31 100644 --- a/src/comments/models/toSelf/index.ts +++ b/src/comments/models/toSelf/index.ts @@ -1,7 +1,5 @@ -export { DeletedProps } from "./deleted.props"; export { RepliedProps } from "./replied.props"; export enum CommentToSelfRelTypes { REPLIED = "REPLIED", - DELETED = "DELETED", } diff --git a/src/comments/repositories/comment/comments.repository.interface.ts b/src/comments/repositories/comment/comments.repository.interface.ts index 88347b0..f87aed9 100644 --- a/src/comments/repositories/comment/comments.repository.interface.ts +++ b/src/comments/repositories/comment/comments.repository.interface.ts @@ -1,20 +1,26 @@ -import { RestrictedProps } from "../../../_domain/models/toSelf"; +import { DeletedProps, RestrictedProps } from "../../../_domain/models/toSelf"; import { Comment } from "../../models"; +import { Post } from "../../../posts/models"; export interface ICommentsRepository { findAll(): Promise; - findCommentById(commentId: string): Promise; + findCommentById(commentId: UUID): Promise; - findChildrenComments(parentId: string): Promise; + updateComment(comment: Comment): Promise; addCommentToComment(comment: Comment): Promise; addCommentToPost(comment: Comment): Promise; - deleteComment(commentId: string): Promise; + deleteComment(commentId: UUID): Promise; - restrictComment(commentId: string, restrictedProps: RestrictedProps): Promise; + restrictComment(commentId: UUID, restrictedProps: RestrictedProps): Promise; - unrestrictComment(commentId: string): Promise; + unrestrictComment(commentId: UUID): Promise; + + markAsDeleted(commentId: UUID, deletedProps: DeletedProps): Promise; + removeDeletedMark(commentId: UUID): Promise; + + findParentPost(commentId: UUID): Promise; } diff --git a/src/comments/repositories/comment/comments.repository.ts b/src/comments/repositories/comment/comments.repository.ts index 05d2f1a..1a64e49 100644 --- a/src/comments/repositories/comment/comments.repository.ts +++ b/src/comments/repositories/comment/comments.repository.ts @@ -1,25 +1,26 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { HttpException, Inject, Injectable } from "@nestjs/common"; import { Neo4jService } from "../../../neo4j/services/neo4j.service"; import { AuthoredProps, UserToCommentRelTypes } from "../../../users/models/toComment"; -import { RestrictedProps, _ToSelfRelTypes } from "../../../_domain/models/toSelf"; +import { RestrictedProps, _ToSelfRelTypes, DeletedProps } from "../../../_domain/models/toSelf"; import { Comment } from "../../models"; -import { CommentToSelfRelTypes, RepliedProps } from "../../models/toSelf"; +import { CommentToSelfRelTypes } from "../../models/toSelf"; import { ICommentsRepository } from "./comments.repository.interface"; import { PostToCommentRelTypes } from "../../../posts/models/toComment"; +import { Post } from "../../../posts/models"; @Injectable() export class CommentsRepository implements ICommentsRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allComments = await this._neo4jService.read(`MATCH (c:Comment) RETURN c`, {}); + const allComments = await this._neo4jService.tryReadAsync(`MATCH (c:Comment) RETURN c`, {}); const records = allComments.records; if (records.length === 0) return []; return records.map(record => new Comment(record.get("c").properties, this._neo4jService)); } public async findCommentById(commentId: string): Promise { - const comment = await this._neo4jService.read( + const comment = await this._neo4jService.tryReadAsync( `MATCH (c:Comment) WHERE c.commentId = $commentId RETURN c`, { commentId: commentId } ); @@ -27,21 +28,27 @@ export class CommentsRepository implements ICommentsRepository { return new Comment(comment.records[0].get("c").properties, this._neo4jService); } - public async findChildrenComments(parentId: string): Promise { - const comments = await this._neo4jService.read( - `MATCH (c:Comment)-[:${CommentToSelfRelTypes.REPLIED}]->(p:Comment) WHERE p.commentId = $parentId RETURN c`, - { parentId: parentId } + public async updateComment(comment: Comment): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (c:Comment) WHERE c.commentId = $commentId + SET c.updatedAt = $updatedAt, + c.commentContent = $commentContent, + c.pending = $pending + `, + { + commentId: comment.commentId, + updatedAt: Date.now(), + commentContent: comment.commentContent, + pending: comment.pending, + } ); - const records = comments.records; - if (records.length === 0) return []; - return records.map(record => new Comment(record.get("c").properties, this._neo4jService)); } public async addCommentToComment(comment: Comment): Promise { if (comment.commentId === undefined) { comment.commentId = this._neo4jService.generateId(); } - console.log(comment); let restrictedQueryString = ""; let restrictedQueryParams = {}; if (comment.restrictedProps !== null) { @@ -69,7 +76,7 @@ export class CommentsRepository implements ICommentsRepository { commentContent: $commentContent, pending: $pending })${restrictedQueryString}<-[:${UserToCommentRelTypes.AUTHORED} { - authoredAt: $authoredAt + authoredAt: $authoredProps_authoredAt }]-(u), (c)-[:${CommentToSelfRelTypes.REPLIED}]->(commentParent) `, @@ -78,7 +85,6 @@ export class CommentsRepository implements ICommentsRepository { commentId: comment.commentId, updatedAt: comment.updatedAt, commentContent: comment.commentContent, - authoredAt: authoredProps.authoredAt, // Parent parentId: comment.parentId, @@ -121,23 +127,22 @@ export class CommentsRepository implements ICommentsRepository { await this._neo4jService.tryWriteAsync( ` MATCH (u:User { userId: $userId }) - MATCH (commentParent:Post { postId: $parentId }) + MATCH (parentPost:Post { postId: $parentId }) CREATE (c:Comment { commentId: $commentId, updatedAt: $updatedAt, commentContent: $commentContent, pending: $pending })${restrictedQueryString}<-[:${UserToCommentRelTypes.AUTHORED} { - authoredAt: $authoredAt + authoredAt: $authoredProps_authoredAt }]-(u), - (c)<-[:${PostToCommentRelTypes.HAS_COMMENT}]-(commentParent) + (c)<-[:${PostToCommentRelTypes.HAS_COMMENT}]-(parentPost) `, { // Comment properties commentId: comment.commentId, updatedAt: comment.updatedAt, commentContent: comment.commentContent, - authoredAt: authoredProps.authoredAt, pending: comment.pending, @@ -157,17 +162,14 @@ export class CommentsRepository implements ICommentsRepository { return await this.findCommentById(comment.commentId); } - public async deleteComment(commentId: string): Promise { + public async deleteComment(commentId: UUID): Promise { await this._neo4jService.tryWriteAsync( `MATCH (c:Comment { commentId: $commentId }) DETACH DELETE c`, { commentId: commentId } ); } - public async restrictComment( - commentId: string, - restrictedProps: RestrictedProps - ): Promise { + public async restrictComment(commentId: UUID, restrictedProps: RestrictedProps): Promise { await this._neo4jService.tryWriteAsync( `MATCH (c:Comment { commentId: $commentId }) CREATE (c)-[:${_ToSelfRelTypes.RESTRICTED} { @@ -184,10 +186,53 @@ export class CommentsRepository implements ICommentsRepository { ); } - public async unrestrictComment(commentId: string): Promise { + public async unrestrictComment(commentId: UUID): Promise { await this._neo4jService.tryWriteAsync( `MATCH (c:Comment { commentId: $commentId })-[r:${_ToSelfRelTypes.RESTRICTED}]->(c) DELETE r`, { commentId: commentId } ); } + + public async markAsDeleted(commentId: UUID, deletedProps: DeletedProps): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (c:Comment {commentId: $commentId}) + MERGE (c)-[r:${_ToSelfRelTypes.DELETED}]->(c) + SET r = $deletedProps + `, + { + commentId, + deletedProps, + } + ); + } + + public async removeDeletedMark(commentId: UUID): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (c:Comment {commentId: $commentId})-[r:${_ToSelfRelTypes.DELETED}]->(c) + DELETE r + `, + { + commentId, + } + ); + } + // throw new HttpException("Post not found", 404); + public async findParentPost(commentId: UUID): Promise { + const parentPost = await this._neo4jService.tryReadAsync( + ` + MATCH (p:Post)-[:${PostToCommentRelTypes.HAS_COMMENT}]->(c:Comment { commentId: $commentId }) + RETURN p + `, + { + commentId, + } + ); + + if (parentPost.records.length === 0) { + return undefined; + } + return new Post(parentPost.records[0].get("p").properties, this._neo4jService); + } } diff --git a/src/comments/services/commentReport/commentsReport.service.interface.ts b/src/comments/services/commentReport/commentsReport.service.interface.ts new file mode 100644 index 0000000..71a7eea --- /dev/null +++ b/src/comments/services/commentReport/commentsReport.service.interface.ts @@ -0,0 +1,8 @@ +import { ReportCommentPayloadDto } from "../../dtos"; +import { ReportedProps } from "../../../users/models/toComment"; + +export interface ICommentsReportService { + reportComment(reportCommentPayload: ReportCommentPayloadDto): Promise; + + getReportsForComment(commentId: UUID): Promise; +} diff --git a/src/comments/services/commentReport/commentsReport.service.ts b/src/comments/services/commentReport/commentsReport.service.ts new file mode 100644 index 0000000..bad79fd --- /dev/null +++ b/src/comments/services/commentReport/commentsReport.service.ts @@ -0,0 +1,102 @@ +import { ReportCommentPayloadDto } from "../../dtos"; +import { User } from "../../../users/models"; +import { HttpException, Inject, Injectable, Logger, Scope } from "@nestjs/common"; +import { DatabaseContext } from "../../../database-access-layer/databaseContext"; +import { REQUEST } from "@nestjs/core"; +import { Request } from "express"; +import { _$ } from "../../../_domain/injectableTokens"; +import { ReportedProps, UserToCommentRelTypes } from "../../../users/models/toComment"; +import { ICommentsReportService } from "./commentsReport.service.interface"; + +@Injectable({ scope: Scope.REQUEST }) +export class CommentsReportService implements ICommentsReportService { + private readonly _logger = new Logger(CommentsReportService.name); + private readonly _request: Request; + private readonly _dbContext: DatabaseContext; + + constructor( + @Inject(REQUEST) request: Request, + @Inject(_$.IDatabaseContext) databaseContext: DatabaseContext + ) { + this._request = request; + this._dbContext = databaseContext; + } + + public async reportComment(reportCommentPayload: ReportCommentPayloadDto): Promise { + const user = this.getUserFromRequest(); + + const comment = await this._dbContext.Comments.findCommentById( + reportCommentPayload.commentId + ); + if (!comment) throw new HttpException("Comment not found", 404); + + if (comment.pending || comment.restrictedProps !== null) { + throw new HttpException( + "Comment cannot be reported due to being pending or restricted", + 400 + ); + } + + const report = await this.checkIfUserReportedComment(comment.commentId, user.userId); + + if (report === true) { + throw new HttpException("You have already reported this post", 400); + } + + await comment.getAuthorUser(); + if (comment.authorUser.userId === user.userId) { + throw new HttpException("Comment cannot be reported by comment author", 400); + } + + await this._dbContext.neo4jService.tryWriteAsync( + ` + MATCH (c:Comment { commentId: $commentId }), (u:User { userId: $userId }) + MERGE (u)-[r:${UserToCommentRelTypes.REPORTED}{reportedAt: $reportedAt, reason: $reason}]->(c) + `, + { + reportedAt: Date.now(), + reason: reportCommentPayload.reason, + commentId: comment.commentId, + userId: user.userId, + } + ); + } + + public async getReportsForComment(commentId: UUID): Promise { + const queryResult = await this._dbContext.neo4jService.tryReadAsync( + ` + MATCH (c:Comment { commentId: $commentId })<-[r:${UserToCommentRelTypes.REPORTED}]-(u:User) + RETURN r, u`, + { + commentId: commentId, + } + ); + return queryResult.records.map(record => { + const reportedProps = new ReportedProps(record.get("r").properties); + return reportedProps; + }); + } + + private async checkIfUserReportedComment(commentId: UUID, userId: UUID): Promise { + const queryResult = await this._dbContext.neo4jService.tryReadAsync( + ` + MATCH (p:Comment { commentId: $commentId })<-[r:${UserToCommentRelTypes.REPORTED}]-(u:User { userId: $userId }) + RETURN r + `, + { + commentId: commentId, + userId: userId, + } + ); + if (queryResult.records.length > 0) { + return true; + } + return false; + } + + private getUserFromRequest(): User { + const user = this._request.user as User; + if (user === undefined) throw new HttpException("User not found", 404); + return user; + } +} diff --git a/src/comments/services/comments/comments.service.interface.ts b/src/comments/services/comments/comments.service.interface.ts index f1e2345..543058a 100644 --- a/src/comments/services/comments/comments.service.interface.ts +++ b/src/comments/services/comments/comments.service.interface.ts @@ -1,14 +1,22 @@ +import { CommentCreationPayloadDto, VoteCommentPayloadDto } from "../../dtos"; import { Comment } from "../../models"; -import { VoteCommentPayloadDto, CommentCreationPayloadDto } from "../../dtos"; +import { Post } from "../../../posts/models"; export interface ICommentsService { authorNewComment(commentPayload: CommentCreationPayloadDto): Promise; - findCommentById(commentId: string): Promise; + findCommentById(commentId: UUID): Promise; + + findNestedCommentsByCommentId( + commentId: UUID, + topLevelLimit: number, + nestedLimit: number, + nestedLevel: number + ): Promise; voteComment(votePayload: VoteCommentPayloadDto): Promise; - markAsPinned(commentId: string): Promise; + markAsPinned(commentId: UUID): Promise; - markAsDeleted(commentId: string): Promise; + markAsUnpinned(commentId: UUID): Promise; } diff --git a/src/comments/services/comments/comments.service.ts b/src/comments/services/comments/comments.service.ts index 88ff9a5..fe55860 100644 --- a/src/comments/services/comments/comments.service.ts +++ b/src/comments/services/comments/comments.service.ts @@ -1,34 +1,46 @@ -import { HttpService } from "@nestjs/axios"; -import { HttpException, Inject, Injectable, Scope } from "@nestjs/common"; +import { HttpException, Inject, Injectable, Logger, Scope } from "@nestjs/common"; import { REQUEST } from "@nestjs/core"; import { Request } from "express"; import { DatabaseContext } from "../../../database-access-layer/databaseContext"; +import { IAutoModerationService } from "../../../moderation/services/autoModeration/autoModeration.service.interface"; import { Post } from "../../../posts/models"; import { PostToCommentRelTypes } from "../../../posts/models/toComment"; +import { IPostsService } from "../../../posts/services/posts/posts.service.interface"; import { User } from "../../../users/models"; import { UserToCommentRelTypes } from "../../../users/models/toComment"; +import { VoteProps } from "../../../users/models/toPost"; import { _$ } from "../../../_domain/injectableTokens"; -import { CommentCreationPayloadDto, VoteCommentPayloadDto, VoteType } from "../../dtos"; +import { VoteType } from "../../../_domain/models/enums"; +import { CommentCreationPayloadDto, VoteCommentPayloadDto } from "../../dtos"; import { Comment } from "../../models"; -import { CommentToSelfRelTypes, DeletedProps } from "../../models/toSelf"; +import { CommentToSelfRelTypes } from "../../models/toSelf"; import { ICommentsService } from "./comments.service.interface"; -import { IAutoModerationService } from "../../../moderation/services/autoModeration/autoModeration.service.interface"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { CommentGotPinnedByAuthorEvent, CommentGotVoteEvent, NewCommentEvent } from "../../events"; +import { EventTypes } from "../../../_domain/eventTypes"; @Injectable({ scope: Scope.REQUEST }) export class CommentsService implements ICommentsService { + private readonly _logger = new Logger(CommentsService.name); + + private readonly _eventEmitter: EventEmitter2; private readonly _request: Request; private readonly _dbContext: DatabaseContext; private readonly _autoModerationService: IAutoModerationService; + private readonly _postService: IPostsService; constructor( + eventEmitter: EventEmitter2, @Inject(REQUEST) request: Request, @Inject(_$.IDatabaseContext) databaseContext: DatabaseContext, - httpService: HttpService, - @Inject(_$.IAutoModerationService) autoModerationService: IAutoModerationService + @Inject(_$.IAutoModerationService) autoModerationService: IAutoModerationService, + @Inject(_$.IPostsService) postsService: IPostsService ) { + this._eventEmitter = eventEmitter; this._request = request; this._dbContext = databaseContext; this._autoModerationService = autoModerationService; + this._postService = postsService; } public async authorNewComment(commentPayload: CommentCreationPayloadDto): Promise { @@ -41,18 +53,39 @@ export class CommentsService implements ICommentsService { // if moderation passed, create comment and return it. if (commentPayload.isPost) { - return await this._dbContext.Comments.addCommentToPost( + const foundPost = await this._postService.findPostById(commentPayload.parentId); + const createdComment = await this._dbContext.Comments.addCommentToPost( new Comment({ commentContent: commentPayload.commentContent, authorUser: user, pending: wasOffending, updatedAt: new Date().getTime(), - parentId: commentPayload.parentId, + parentId: foundPost.postId, }) ); + try { + await foundPost.getAuthorUser(); + await foundPost.getPostType(); + this._eventEmitter.emit( + EventTypes.NewCommentOnPost, + new NewCommentEvent({ + subscriberId: foundPost.authorUser.userId, + postId: foundPost.postId, + commentId: createdComment.commentId, + username: user.username, + avatar: user.avatar, + commentContent: createdComment.commentContent, + postTypeName: foundPost.postType.postTypeName, + }) + ); + } catch (error) { + this._logger.error(error); + } + + return createdComment; } - return await this._dbContext.Comments.addCommentToComment( + const createdComment = await this._dbContext.Comments.addCommentToComment( new Comment({ commentContent: commentPayload.commentContent, authorUser: user, @@ -61,9 +94,28 @@ export class CommentsService implements ICommentsService { parentId: commentPayload.parentId, }) ); + try { + const foundParentComment = await this.findCommentById(commentPayload.parentId); + const [parentPost] = await this.findParentCommentRoot(foundParentComment.commentId); + this._eventEmitter.emit( + EventTypes.NewCommentOnComment, + new NewCommentEvent({ + subscriberId: foundParentComment.authorUser.userId, + postId: parentPost.postId, + commentId: createdComment.commentId, + username: user.username, + avatar: user.avatar, + commentContent: createdComment.commentContent, + }) + ); + } catch (error) { + this._logger.error(error); + } + + return createdComment; } - public async findCommentById(commentId: string): Promise { + public async findCommentById(commentId: UUID): Promise { const foundComment = await this._dbContext.Comments.findCommentById(commentId); if (!foundComment) { throw new HttpException("Comment not found", 404); @@ -86,6 +138,24 @@ export class CommentsService implements ICommentsService { return await foundComment.toJSON(); } + public async findNestedCommentsByCommentId( + commentId: string, + topLevelLimit: number, + nestedLimit: number, + nestedLevel: number + ): Promise { + const foundComment = await this._dbContext.Comments.findCommentById(commentId); + if (foundComment === null) throw new HttpException("Comment not found", 404); + + // level 0 means no nesting + const comments = await foundComment.getChildrenComments(topLevelLimit); + if (nestedLevel === 0) return comments; + + await this._postService.getNestedComments(comments, nestedLevel, nestedLimit); + + return comments; + } + public async voteComment(voteCommentPayload: VoteCommentPayloadDto): Promise { const user = this.getUserFromRequest(); @@ -97,6 +167,7 @@ export class CommentsService implements ICommentsService { const queryResult = await this._dbContext.neo4jService.tryReadAsync( ` MATCH (u:User { userId: $userId })-[r:${UserToCommentRelTypes.UPVOTES}|${UserToCommentRelTypes.DOWN_VOTES}]->(c:Comment { commentId: $commentId }) + RETURN r `, { userId: user.userId, @@ -105,106 +176,158 @@ export class CommentsService implements ICommentsService { ); if (queryResult.records.length > 0) { + // user has already voted on this comment const relType = queryResult.records[0].get("r").type; - if ( - relType === UserToCommentRelTypes.UPVOTES && - voteCommentPayload.voteType === VoteType.UPVOTES - ) { - throw new HttpException("User already upvoted this comment", 400); - } else if ( - relType === UserToCommentRelTypes.DOWN_VOTES && - voteCommentPayload.voteType === VoteType.DOWN_VOTES - ) { - throw new HttpException("User already downvoted this comment", 400); - } else { - await this._dbContext.neo4jService.tryWriteAsync( - ` + + // remove the existing vote + await this._dbContext.neo4jService.tryWriteAsync( + ` MATCH (u:User { userId: $userId })-[r:${relType}]->(c:Comment { commentId: $commentId }) DELETE r `, - { - userId: user.userId, - commentId: voteCommentPayload.commentId, - } - ); + { + userId: user.userId, + commentId: voteCommentPayload.commentId, + } + ); + + // don't add a new vote if the user is removing their vote (stop) + if ( + (relType === UserToCommentRelTypes.UPVOTES && + voteCommentPayload.voteType === VoteType.UPVOTES) || + (relType === UserToCommentRelTypes.DOWN_VOTES && + voteCommentPayload.voteType === VoteType.DOWN_VOTES) + ) { + return; } } + + // add the new vote + const voteProps = new VoteProps({ + votedAt: new Date().getTime(), + }); + await this._dbContext.neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId }), (c:Comment { commentId: $commentId }) + MERGE (u)-[r:${voteCommentPayload.voteType} { votedAt: $votedAt }]->(c) + `, + { + userId: user.userId, + commentId: voteCommentPayload.commentId, + + votedAt: voteProps.votedAt, + } + ); + + const eventType = + voteCommentPayload.voteType === VoteType.UPVOTES + ? EventTypes.CommentGotUpVote + : EventTypes.CommentGotDownVote; + + // don't wait for the push notification. + try { + const [parentPost] = await this.findParentCommentRoot(comment.commentId); + await parentPost.getAuthorUser(); + this._eventEmitter.emit( + eventType, + new CommentGotVoteEvent({ + subscriberId: parentPost.authorUser.userId, + postId: parentPost.postId, + commentId: comment.commentId, + username: user.username, + avatar: user.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } } - public async markAsPinned(commentId: string): Promise { + public async markAsPinned(commentId: UUID): Promise { const comment = await this._dbContext.Comments.findCommentById(commentId); if (!comment) throw new HttpException("Comment not found", 404); + const [parentPost] = await this.findParentCommentRoot(commentId); const user = this.getUserFromRequest(); + const isPinned = await this.checkIfAnyCommentIsPinned(parentPost); - const [parentPost] = await this.findParentCommentRoot(commentId); + const postAuthor = await parentPost.getAuthorUser(); - if (parentPost.authorUser.userId !== user.userId) { + if (postAuthor.userId !== user.userId) { throw new HttpException("User is not the author of the post", 403); } + if (isPinned === true) { + throw new HttpException("Post already has a pinned comment", 400); + } + await this._dbContext.neo4jService.tryWriteAsync( ` - MATCH (c:Comment { commentId: $commentId }) - SET c.pinned = true + MATCH (p:Post { postId: $postId }), (c:Comment { commentId: $commentId }) + MERGE (p)-[:${PostToCommentRelTypes.PINNED_COMMENT}]->(c) `, { - commentId, + postId: parentPost.postId, + commentId: commentId, } ); + + try { + await comment.getAuthorUser(); + this._eventEmitter.emit( + EventTypes.CommentGotPinnedByAuthor, + new CommentGotPinnedByAuthorEvent({ + subscriberId: comment.authorUser.userId, + commentId, + commentContent: comment.commentContent, + postId: parentPost.postId, + username: user.username, + avatar: user.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } } - public async markAsDeleted(commentId: string): Promise { + public async markAsUnpinned(commentId: UUID): Promise { const comment = await this._dbContext.Comments.findCommentById(commentId); - if (!comment) { - throw new HttpException("Comment not found", 404); - } + if (!comment) throw new HttpException("Comment not found", 404); - await comment.getDeletedProps(); - if (comment.deletedProps) { - throw new HttpException("Comment was already deleted", 400); - } + const [parentPost] = await this.findParentCommentRoot(commentId); + const user = this.getUserFromRequest(); - await comment.getAuthorUser(); + const postAuthor = await parentPost.getAuthorUser(); - await comment.setDeletedProps( - new DeletedProps({ - deletedAt: new Date().getTime(), - deletedByUserId: comment.authorUser.userId, - }) - ); - } + if (postAuthor.userId !== user.userId) { + throw new HttpException("User is not the author of the post", 403); + } - // gets the parent post of any nested comment of the post - private async findParentPost(commentId: string): Promise { - const parentPost = await this._dbContext.neo4jService.tryReadAsync( + const queryResult = await this._dbContext.neo4jService.tryWriteAsync( ` - MATCH (p:Post)-[:${PostToCommentRelTypes.HAS_COMMENT}]->(c:Comment { commentId: $commentId }) - RETURN p + MATCH (p:Post { postId: $postId })-[r:${PostToCommentRelTypes.PINNED_COMMENT}]->(c:Comment { commentId: $commentId }) + DELETE r `, { - commentId, + postId: parentPost.postId, + commentId: commentId, } ); - if (parentPost.records.length === 0) { - throw new HttpException("Post not found", 404); + // Checks if a change was made to the database (If the comment was unpinned) + if (queryResult.summary.counters.containsUpdates() === false) { + throw new HttpException("Comment is not pinned", 400); } - return parentPost.records[0].get("p"); } - // gets the parent comment of any nested comment of the post - private async findComment(commentId: string): Promise { - const queryResult = await this._dbContext.neo4jService.tryReadAsync( - ` - MATCH (c:Comment { commentId: $commentId })-[:${CommentToSelfRelTypes.REPLIED}]->(commentParent:Comment) - RETURN commentParent - `, - { - commentId, - } - ); - return queryResult.records[0].get("commentParent"); + // gets the parent post of any nested comment of the post + private async acquireParentPost(commentId: UUID): Promise { + const parentPost = await this._dbContext.Comments.findParentPost(commentId); + + if (!parentPost) { + throw new HttpException("Post not found", 404); + } + return parentPost; } // gets the root comment of any nested comment @@ -214,27 +337,40 @@ export class CommentsService implements ICommentsService { ): Promise<[Post, boolean]> { const queryResult = await this._dbContext.neo4jService.tryReadAsync( ` - MATCH (c:Comment { commentId: $commentId })-[:${CommentToSelfRelTypes.REPLIED}]->(c:Comment) - RETURN c - `, + MATCH (c:Comment { commentId: $commentId })-[:${CommentToSelfRelTypes.REPLIED}]->(commentParent:Comment) + RETURN commentParent + `, { commentId, } ); if (queryResult.records.length > 0) { return await this.findParentCommentRoot( - queryResult.records[0].get("c").properties.commentId, + queryResult.records[0].get("commentParent").properties.commentId, true ); } else { const rootComment = await this._dbContext.Comments.findCommentById(commentId); - return [await this.findParentPost(rootComment.commentId), isNestedComment]; + return [await this.acquireParentPost(rootComment.commentId), isNestedComment]; } } + private async checkIfAnyCommentIsPinned(post: Post): Promise { + const queryResult = await this._dbContext.neo4jService.tryReadAsync( + ` + MATCH (p:Post { postId: $postId })-[:${PostToCommentRelTypes.PINNED_COMMENT}]->(c:Comment) + RETURN c + `, + { + postId: post.postId, + } + ); + return queryResult.records.length > 0; + } + private getUserFromRequest(): User { const user = this._request.user as User; - if (user === undefined) throw new Error("User not found"); + if (user === undefined) throw new HttpException("User not found", 404); return user; } } diff --git a/src/database-access-layer/databaseContext.ts b/src/database-access-layer/databaseContext.ts index ea7daee..601b790 100644 --- a/src/database-access-layer/databaseContext.ts +++ b/src/database-access-layer/databaseContext.ts @@ -8,6 +8,7 @@ import { IGenderRepository } from "../users/repositories/gender/gender.repositor import { ISexualityRepository } from "../users/repositories/sexuality/sexuality.repository.interface"; import { IUsersRepository } from "../users/repositories/users/users.repository.interface"; import { _$ } from "../_domain/injectableTokens"; +import { IOpennessRepository } from "../users/repositories/openness/openness.repository.interface"; @Injectable() export class DatabaseContext { @@ -21,6 +22,7 @@ export class DatabaseContext { @Inject(_$.IUsersRepository) usersRepository: IUsersRepository, @Inject(_$.ISexualityRepository) sexualityRepository: ISexualityRepository, @Inject(_$.IGenderRepository) genderRepository: IGenderRepository, + @Inject(_$.IOpennessRepository) opennessRepository: IOpennessRepository, @Inject(_$.ICommentsRepository) commentsRepository: ICommentsRepository ) { this.neo4jService = neo4jService; @@ -30,6 +32,7 @@ export class DatabaseContext { this.PostTags = postTagsRepository; this.Users = usersRepository; this.Sexualities = sexualityRepository; + this.Openness = opennessRepository; this.Genders = genderRepository; this.Comments = commentsRepository; } @@ -39,6 +42,7 @@ export class DatabaseContext { public PostTags: IPostTagsRepository; public Users: IUsersRepository; public Sexualities: ISexualityRepository; + public Openness: IOpennessRepository; public Genders: IGenderRepository; public Comments: ICommentsRepository; } diff --git a/src/global.d.ts b/src/global.d.ts index 7073af4..5667f36 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1 +1,3 @@ type Nullable = T | null; + +type UUID = string; diff --git a/src/google-cloud-recaptcha-enterprise/captcha.guard.ts b/src/google-cloud-recaptcha-enterprise/captcha.guard.ts new file mode 100644 index 0000000..349aa8a --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/captcha.guard.ts @@ -0,0 +1,48 @@ +import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common"; +import { _$ } from "../_domain/injectableTokens"; +import { IGoogleCloudRecaptchaEnterpriseService } from "./google-cloud-recaptcha-enterprise.service.interface"; +import { + captchaConfigMetaDataKey, + headerKeys as googleCloudRecaptchaEnterpriseHeaders, +} from "./google-cloud-recaptcha-enterprise.constants"; +import { AssessmentDto } from "./models/assessment.dto"; +import { Reflector } from "@nestjs/core"; +import { CaptchaConfigParameters } from "./models/captchaConfigParameters.interface"; + +@Injectable() +export class CaptchaGuard implements CanActivate { + constructor( + @Inject(_$.IGoogleCloudRecaptchaEnterpriseService) + private readonly _recaptchaEnterpriseService: IGoogleCloudRecaptchaEnterpriseService, + private reflector: Reflector + ) {} + + public async canActivate(context: ExecutionContext): Promise { + const captchaConfig = this.reflector.get( + captchaConfigMetaDataKey, + context.getHandler() + ); + + const request = context.switchToHttp().getRequest(); + + const headers = request.headers; + const token = headers[googleCloudRecaptchaEnterpriseHeaders.token]; + const recaptchaAction = headers[googleCloudRecaptchaEnterpriseHeaders.recaptchaAction]; + + if (!token || !recaptchaAction) { + return false; + } + + const assessment = await this._recaptchaEnterpriseService.createAssessment( + new AssessmentDto({ + token, + recaptchaAction, + }) + ); + if (assessment === null) { + return false; + } + + return assessment > (captchaConfig?.passScore || 0.6); + } +} diff --git a/src/google-cloud-recaptcha-enterprise/captchaConfig.decorator.ts b/src/google-cloud-recaptcha-enterprise/captchaConfig.decorator.ts new file mode 100644 index 0000000..cf5efbe --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/captchaConfig.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from "@nestjs/common"; +import { CaptchaConfigParameters } from "./models/captchaConfigParameters.interface"; +import { captchaConfigMetaDataKey } from "./google-cloud-recaptcha-enterprise.constants"; + +export const CaptchaConfig = (config: CaptchaConfigParameters) => + SetMetadata(captchaConfigMetaDataKey, config); diff --git a/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.constants.ts b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.constants.ts new file mode 100644 index 0000000..7836e9a --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.constants.ts @@ -0,0 +1,11 @@ +export const envKeys = { + projectId: "GOOGLE_CLOUD_RECAPTCHA_ENTERPRISE_PROJECT_ID", + recaptchaSiteKey: "GOOGLE_CLOUD_RECAPTCHA_ENTERPRISE_RECAPTCHA_SITE_KEY", +}; + +export const headerKeys = { + token: "IGAQ-reCaptcahToken".toLowerCase(), + recaptchaAction: "IGAQ-reCaptcahAction".toLowerCase(), +}; + +export const captchaConfigMetaDataKey = "CAPTCHA_CONFIG"; diff --git a/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module.ts b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module.ts new file mode 100644 index 0000000..a1bede7 --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { _$ } from "../_domain/injectableTokens"; +import { GoogleCloudRecaptchaEnterpriseService } from "./google-cloud-recaptcha-enterprise.service"; +import { ConfigModule } from "@nestjs/config"; + +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: _$.IGoogleCloudRecaptchaEnterpriseService, + useClass: GoogleCloudRecaptchaEnterpriseService, + }, + ], + exports: [ + { + provide: _$.IGoogleCloudRecaptchaEnterpriseService, + useClass: GoogleCloudRecaptchaEnterpriseService, + }, + ], +}) +export class GoogleCloudRecaptchaEnterpriseModule {} diff --git a/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.interface.ts b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.interface.ts new file mode 100644 index 0000000..e4f59bd --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.interface.ts @@ -0,0 +1,5 @@ +import { AssessmentDto } from "./models/assessment.dto"; + +export interface IGoogleCloudRecaptchaEnterpriseService { + createAssessment(assessmentPayload: AssessmentDto): Promise; +} diff --git a/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.ts b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.ts new file mode 100644 index 0000000..bdffd43 --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.service.ts @@ -0,0 +1,81 @@ +import { ConfigService } from "@nestjs/config"; +import { envKeys } from "./google-cloud-recaptcha-enterprise.constants"; +import { AssessmentDto } from "./models/assessment.dto"; +import { RecaptchaEnterpriseServiceClient } from "@google-cloud/recaptcha-enterprise"; +import { IGoogleCloudRecaptchaEnterpriseService } from "./google-cloud-recaptcha-enterprise.service.interface"; +import { Injectable, Scope } from "@nestjs/common"; + +@Injectable({ scope: Scope.REQUEST }) +export class GoogleCloudRecaptchaEnterpriseService + implements IGoogleCloudRecaptchaEnterpriseService +{ + constructor(private _configService: ConfigService) {} + + /** + * Create an assessment to analyze the risk of an UI action. Note that + * this example does set error boundaries and returns `null` for + * exceptions. + * + * projectID: GCloud Project ID + * recaptchaSiteKey: Site key obtained by registering a domain/app to use recaptcha services. + * token: The token obtained from the client on passing the recaptchaSiteKey. + * recaptchaAction: Action name corresponding to the token. + */ + public async createAssessment(assessmentPayload: AssessmentDto): Promise { + const projectID = this._configService.get(envKeys.projectId); + const recaptchaSiteKey = this._configService.get(envKeys.recaptchaSiteKey); + + // Create the reCAPTCHA client & set the project path. There are multiple + // ways to authenticate your client. For more information see: + // https://cloud.google.com/docs/authentication + // TODO: To avoid memory issues, move this client generation outside + // of this example, and cache it (recommended) or call client.close() + // before exiting this method. + const client = new RecaptchaEnterpriseServiceClient(); + const projectPath = client.projectPath(projectID); + + const request = { + assessment: { + event: { + token: assessmentPayload.token, + siteKey: recaptchaSiteKey, + }, + }, + parent: projectPath, + }; + + // client.createAssessment() can return a Promise or take a Callback + const [response] = await client.createAssessment(request); + + // Check if the token is valid. + if (!response.tokenProperties.valid) { + console.log( + "The CreateAssessment call failed because the token was: " + + response.tokenProperties.invalidReason + ); + + return null; + } + + // Check if the expected action was executed. + // The `action` property is set by user client in the + // grecaptcha.enterprise.execute() method. + if (response.tokenProperties.action === assessmentPayload.recaptchaAction) { + // Get the risk score and the reason(s). + // For more information on interpreting the assessment, + // see: https://cloud.google.com/recaptcha-enterprise/docs/interpret-assessment + console.log("The reCAPTCHA score is: " + response.riskAnalysis.score); + + response.riskAnalysis.reasons.forEach(reason => { + console.log(reason); + }); + return response.riskAnalysis.score; + } else { + console.log( + "The action attribute in your reCAPTCHA tag " + + "does not match the action you are expecting to score" + ); + return null; + } + } +} diff --git a/src/google-cloud-recaptcha-enterprise/models/assessment.dto.ts b/src/google-cloud-recaptcha-enterprise/models/assessment.dto.ts new file mode 100644 index 0000000..6c037d8 --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/models/assessment.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; +import { UserActionsEnum } from "./userActions.enum"; + +export class AssessmentDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsEnum(UserActionsEnum) + recaptchaAction: UserActionsEnum; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/google-cloud-recaptcha-enterprise/models/captchaConfigParameters.interface.ts b/src/google-cloud-recaptcha-enterprise/models/captchaConfigParameters.interface.ts new file mode 100644 index 0000000..1945a32 --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/models/captchaConfigParameters.interface.ts @@ -0,0 +1,3 @@ +export interface CaptchaConfigParameters { + passScore: number; +} diff --git a/src/google-cloud-recaptcha-enterprise/models/userActions.enum.ts b/src/google-cloud-recaptcha-enterprise/models/userActions.enum.ts new file mode 100644 index 0000000..65ed95d --- /dev/null +++ b/src/google-cloud-recaptcha-enterprise/models/userActions.enum.ts @@ -0,0 +1,8 @@ +export enum UserActionsEnum { + ChangePassword = "ChangePassword", + CreateComment = "CreateComment", + CreatePost = "CreatePost", + Login = "Login", + SignUp = "SignUp", + ContentReport = "ContentReport", +} diff --git a/src/moderation/controllers/moderation.controller.spec.ts b/src/moderation/controllers/moderation.controller.spec.ts new file mode 100644 index 0000000..94e4090 --- /dev/null +++ b/src/moderation/controllers/moderation.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ModerationController } from './moderation.controller'; + +describe('ModerationController', () => { + let controller: ModerationController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ModerationController], + }).compile(); + + controller = module.get(ModerationController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/moderation/controllers/moderation.controller.ts b/src/moderation/controllers/moderation.controller.ts new file mode 100644 index 0000000..0128c3b --- /dev/null +++ b/src/moderation/controllers/moderation.controller.ts @@ -0,0 +1,163 @@ +import { + Body, + CacheInterceptor, + CacheTTL, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Inject, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { AuthedUser } from "../../auth/decorators/authedUser.param.decorator"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { Roles } from "../../auth/decorators/roles.decorator"; +import { RolesGuard } from "../../auth/guards/roles.guard"; +import { Role, User } from "../../users/models"; +import { _$ } from "../../_domain/injectableTokens"; +import { ModerationPayloadDto } from "../dtos"; +import { IModeratorActionsService } from "../services/moderatorActions/moderatorActions.service.interface"; + +@ApiTags("moderation") +@Controller("moderation") +@ApiBearerAuth() +@UseInterceptors(ClassSerializerInterceptor) +export class ModerationController { + private readonly _moderationActionsService: IModeratorActionsService; + + constructor( + @Inject(_$.IModeratorActionsService) moderationActionsService: IModeratorActionsService + ) { + this._moderationActionsService = moderationActionsService; + } + + @Patch("/post/:postId/allow") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async allowPost(@Param("postId", ParseUUIDPipe) postId: UUID): Promise { + await this._moderationActionsService.allowPost(postId); + } + + @Patch("/post/restrict") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async restrictPost( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderationActionsService.restrictPost(moderationPayload); + } + + @Patch("/post/:postId/unrestrict") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async unrestrictPost(@Param("postId", ParseUUIDPipe) postId: UUID): Promise { + await this._moderationActionsService.unrestrictPost(postId); + } + + @Patch("/comment/:commentId/allow") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async allowComment(@Param("commentId", ParseUUIDPipe) commentId: UUID): Promise { + await this._moderationActionsService.allowComment(commentId); + } + + @Patch("/comment/restrict") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async restrictComment( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderationActionsService.restrictComment(moderationPayload); + } + + @Patch("/comment/:commentId/unrestrict") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async unrestrictComment( + @Param("commentId", ParseUUIDPipe) commentId: UUID + ): Promise { + await this._moderationActionsService.unrestrictComment(commentId); + } + + @Patch("/post/delete") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async deletePost( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderationActionsService.deletePost(moderationPayload); + } + + @Patch("/post/:postId/restore") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async restorePost(@Param("postId", ParseUUIDPipe) postId: UUID): Promise { + await this._moderationActionsService.restorePost(postId); + } + + @Patch("/comment/delete") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async deleteComment( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderationActionsService.deleteComment(moderationPayload); + } + + @Patch("/comment/:commentId/restore") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async restoreComment(@Param("commentId", ParseUUIDPipe) commentId: UUID): Promise { + await this._moderationActionsService.restoreComment(commentId); + } + + @Patch("/user/:userId/unban") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async unbanUser(@Param("userId", ParseUUIDPipe) userId: UUID): Promise { + await this._moderationActionsService.unbanUser(userId); + } + + @Patch("/user/ban") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async banUser( + @AuthedUser() user: User, + @Body() moderationPayload: ModerationPayloadDto + ): Promise { + moderationPayload.moderatorId = user.userId; + await this._moderationActionsService.banUser(moderationPayload); + } + + @Get("/pendingPosts") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async getPendingPosts() { + const posts = await this._moderationActionsService.getPendingPosts(); + const decoratedPosts = posts.map(post => post.toJSON()); + return await Promise.all(decoratedPosts); + } + + @Get("/deletedPosts") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async getDeletedPosts() { + const posts = await this._moderationActionsService.getDeletedPosts(); + const decoratedPosts = posts.map(post => post.toJSON()); + return await Promise.all(decoratedPosts); + } +} diff --git a/src/comments/dtos/hateSpeechRequestPayload.dto.ts b/src/moderation/dtos/hateSpeechRequestPayload.dto.ts similarity index 68% rename from src/comments/dtos/hateSpeechRequestPayload.dto.ts rename to src/moderation/dtos/hateSpeechRequestPayload.dto.ts index 3336512..4fed731 100644 --- a/src/comments/dtos/hateSpeechRequestPayload.dto.ts +++ b/src/moderation/dtos/hateSpeechRequestPayload.dto.ts @@ -1,6 +1,10 @@ +import { IsString } from "class-validator"; + export class HateSpeechRequestPayloadDto { + @IsString() token: string; + @IsString() text: string; constructor(partial?: Partial) { diff --git a/src/comments/dtos/hateSpeechResponse.dto.ts b/src/moderation/dtos/hateSpeechResponse.dto.ts similarity index 63% rename from src/comments/dtos/hateSpeechResponse.dto.ts rename to src/moderation/dtos/hateSpeechResponse.dto.ts index e31e26e..7776aa7 100644 --- a/src/comments/dtos/hateSpeechResponse.dto.ts +++ b/src/moderation/dtos/hateSpeechResponse.dto.ts @@ -1,8 +1,13 @@ +import { IsNumber, IsString } from "class-validator"; + export class HateSpeechResponseDto { + @IsString() response: string; + @IsString() class: string; + @IsNumber() confidence: number; constructor(partial?: Partial) { diff --git a/src/moderation/dtos/index.ts b/src/moderation/dtos/index.ts new file mode 100644 index 0000000..c1d2e1b --- /dev/null +++ b/src/moderation/dtos/index.ts @@ -0,0 +1,3 @@ +export * from "./hateSpeechRequestPayload.dto"; +export * from "./hateSpeechResponse.dto"; +export * from "./moderatorActions"; diff --git a/src/moderation/dtos/moderatorActions/index.ts b/src/moderation/dtos/moderatorActions/index.ts new file mode 100644 index 0000000..349777c --- /dev/null +++ b/src/moderation/dtos/moderatorActions/index.ts @@ -0,0 +1 @@ +export * from "./moderationPayload.dto"; diff --git a/src/moderation/dtos/moderatorActions/moderationPayload.dto.ts b/src/moderation/dtos/moderatorActions/moderationPayload.dto.ts new file mode 100644 index 0000000..277d0f8 --- /dev/null +++ b/src/moderation/dtos/moderatorActions/moderationPayload.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; +import { Exclude } from "class-transformer"; + +export class ModerationPayloadDto { + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + id: UUID; + + @IsUUID() + @IsOptional() + @Exclude() + moderatorId: UUID; + + @ApiProperty({ type: String }) + @IsString() + @IsNotEmpty() + reason: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/moderation/events/commentGotApprovedByModerator.event.ts b/src/moderation/events/commentGotApprovedByModerator.event.ts new file mode 100644 index 0000000..1faf6e1 --- /dev/null +++ b/src/moderation/events/commentGotApprovedByModerator.event.ts @@ -0,0 +1,15 @@ +export class CommentGotApprovedByModeratorEvent { + subscriberId: UUID; + + commentId: UUID; + + postId: UUID; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/moderation/events/commentGotRestricted.event.ts b/src/moderation/events/commentGotRestricted.event.ts new file mode 100644 index 0000000..b07296a --- /dev/null +++ b/src/moderation/events/commentGotRestricted.event.ts @@ -0,0 +1,15 @@ +export class CommentGotRestrictedEvent { + subscriberUserId: UUID; + + commentContent: string; + + reason: string; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(partial); + } +} diff --git a/src/moderation/events/index.ts b/src/moderation/events/index.ts new file mode 100644 index 0000000..41b0b7a --- /dev/null +++ b/src/moderation/events/index.ts @@ -0,0 +1,4 @@ +export * from "./postGotApprovedByModerator.event"; +export * from "./postGotRestricted.event"; +export * from "./commentGotApprovedByModerator.event"; +export * from "./commentGotRestricted.event"; diff --git a/src/moderation/events/postGotApprovedByModerator.event.ts b/src/moderation/events/postGotApprovedByModerator.event.ts new file mode 100644 index 0000000..d288ba5 --- /dev/null +++ b/src/moderation/events/postGotApprovedByModerator.event.ts @@ -0,0 +1,13 @@ +export class PostGotApprovedByModeratorEvent { + subscriberId: UUID; + + postId: UUID; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/moderation/events/postGotRestricted.event.ts b/src/moderation/events/postGotRestricted.event.ts new file mode 100644 index 0000000..3ae0e13 --- /dev/null +++ b/src/moderation/events/postGotRestricted.event.ts @@ -0,0 +1,15 @@ +export class PostGotRestrictedEvent { + subscriberId: UUID; + + postTitle: string; + + reason: string; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/moderation/moderation.module.ts b/src/moderation/moderation.module.ts index 2e36230..315df9a 100644 --- a/src/moderation/moderation.module.ts +++ b/src/moderation/moderation.module.ts @@ -1,21 +1,33 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { AutoModerationService } from "./services/autoModeration/autoModeration.service"; +import { ModeratorActionsService } from "./services/moderatorActions/moderatorActions.service"; import { _$ } from "../_domain/injectableTokens"; import { HttpModule } from "@nestjs/axios"; +import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module"; +import { ModerationController } from "./controllers/moderation.controller"; @Module({ - imports: [HttpModule], + imports: [HttpModule, forwardRef(() => DatabaseAccessLayerModule)], providers: [ { provide: _$.IAutoModerationService, useClass: AutoModerationService, }, + { + provide: _$.IModeratorActionsService, + useClass: ModeratorActionsService, + }, ], exports: [ { provide: _$.IAutoModerationService, useClass: AutoModerationService, }, + { + provide: _$.IModeratorActionsService, + useClass: ModeratorActionsService, + }, ], + controllers: [ModerationController], }) export class ModerationModule {} diff --git a/src/moderation/services/autoModeration/autoModeration.service.ts b/src/moderation/services/autoModeration/autoModeration.service.ts index 253b947..bd3cfc3 100644 --- a/src/moderation/services/autoModeration/autoModeration.service.ts +++ b/src/moderation/services/autoModeration/autoModeration.service.ts @@ -5,7 +5,7 @@ import { ConfigService } from "@nestjs/config"; import { catchError, lastValueFrom, map, throwError } from "rxjs"; import { HttpService } from "@nestjs/axios"; import { IAutoModerationService } from "./autoModeration.service.interface"; -import { HateSpeechRequestPayloadDto, HateSpeechResponseDto } from "../../../posts/dtos"; +import { HateSpeechRequestPayloadDto, HateSpeechResponseDto } from "../../dtos"; import { WasOffendingProps } from "../../../users/models/toSelf"; import { User } from "../../../users/models"; @@ -51,7 +51,7 @@ export class AutoModerationService implements IAutoModerationService { // if moderation failed, throw error if (hateSpeechResponseDto.class === "flag") { - if (hateSpeechResponseDto.confidence >= 0.9) { + if (hateSpeechResponseDto.confidence >= 0.9001) { // TODO: create a ticket for the admin to review await user.addWasOffendingRecord( @@ -92,7 +92,7 @@ export class AutoModerationService implements IAutoModerationService { private getUserFromRequest(): User { const user = this._request.user as User; - if (user === undefined) throw new Error("User not found"); + if (user === undefined) throw new HttpException("User not found", 404); return user; } } diff --git a/src/moderation/services/moderatorActions/moderatorActions.service.interface.ts b/src/moderation/services/moderatorActions/moderatorActions.service.interface.ts new file mode 100644 index 0000000..abdad0c --- /dev/null +++ b/src/moderation/services/moderatorActions/moderatorActions.service.interface.ts @@ -0,0 +1,92 @@ +import { Post } from "../../../posts/models"; +import { Comment } from "../../../comments/models"; +import { ModerationPayloadDto } from "../../dtos/moderatorActions"; + +export interface IModeratorActionsService { + /** + * Updates the pending status of a post, and makes it visible to the public. + * @param postId + */ + allowPost(postId: UUID): Promise; + + /** + * Restricts a post by an adding a self relationship to the post. + * @param payload + */ + restrictPost(payload: ModerationPayloadDto): Promise; + /** + * Removes the restriction of a post. It will remove the restriction self-relation from the post. + * If the post is already unrestricted, it will silently resolve the promise. + * Notes: + * * This method will not check if the end user has the permission to remove the restriction. + * * This method will give a http 404 if the post was not found. + * @param postId + */ + unrestrictPost(postId: UUID): Promise; + + /** + * Updates the pending status of a comment, and makes it visible to the public. + * @param commentId + */ + allowComment(commentId: UUID): Promise; + + /** + * Restricts a post by an adding a self relationship to the post. + * @param payload + */ + restrictComment(payload: ModerationPayloadDto): Promise; + /** + * Removes the restriction of a comment. It will remove the restriction self-relation from the comment. + * If the comment is already unrestricted, it will silently resolve the promise. + * Notes: + * * This method will not check if the end user has the permission to remove the restriction. + * * This method will give a http 404 if the comment was not found. + * @param commentId + */ + unrestrictComment(commentId: UUID): Promise; + + /** + * Adds the mark of "deleted" to the post. + * @param payload + */ + deletePost(payload: ModerationPayloadDto): Promise; + /** + * Removes the mark of "deleted" from the post. + * @param postId + */ + restorePost(postId: UUID): Promise; + + /** + * Adds the mark of "deleted" to the comment. + * @param payload + */ + deleteComment(payload: ModerationPayloadDto): Promise; + /** + * Removes the mark of deleted from a comment. + * @param commentId + */ + restoreComment(commentId: UUID): Promise; + + /** + * Bans a user. A banned user cannot post, comment, or vote. + * TODO: + * - Implement this. + * - Somehow add these actions to a history of actions records, so that it can be tracked and analyzed in the future. + * - This will be a stretch goal. For now, we will just ban the user by adding a self relationship to the user node. + * @param payload + */ + banUser(payload: ModerationPayloadDto): Promise; + /** + * Unbans a user. + * TODO: + * - Implement this. + * - Somehow add these actions to a history of actions records, so that it can be tracked and analyzed in the future. + * - This will be a stretch goal. For now, we will just unban the user by removing the self relationship to the user node. + * @param userId + */ + unbanUser(userId: UUID): Promise; + + getPendingPosts(): Promise; + + getDeletedPosts(): Promise; +} diff --git a/src/moderation/services/moderatorActions/moderatorActions.service.ts b/src/moderation/services/moderatorActions/moderatorActions.service.ts new file mode 100644 index 0000000..8569657 --- /dev/null +++ b/src/moderation/services/moderatorActions/moderatorActions.service.ts @@ -0,0 +1,339 @@ +import { IModeratorActionsService } from "./moderatorActions.service.interface"; +import { HttpException, Inject, Injectable, Logger, Scope } from "@nestjs/common"; +import { _$ } from "../../../_domain/injectableTokens"; +import { DatabaseContext } from "../../../database-access-layer/databaseContext"; +import { DeletedProps, RestrictedProps } from "../../../_domain/models/toSelf"; +import { Comment } from "../../../comments/models"; +import { Post } from "../../../posts/models"; +import { ModerationPayloadDto } from "../../dtos"; +import { GotBannedProps } from "../../../users/models/toSelf"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PostGotApprovedByModeratorEvent, PostGotRestrictedEvent } from "../../events"; +import { CommentGotApprovedByModeratorEvent, CommentGotRestrictedEvent } from "../../events"; +import { EventTypes } from "../../../_domain/eventTypes"; + +/** + * This service is responsible for moderating posts and comments. + * It is used by the moderator actions controller. + * Notes: + * - This service is not responsible to check if the user is a moderator. This is done by the guards used by controllers. + * @see src/moderation/controllers/moderatorActions/moderatorActions.controller.ts + */ +@Injectable({ scope: Scope.REQUEST }) +export class ModeratorActionsService implements IModeratorActionsService { + private readonly _logger: Logger = new Logger(ModeratorActionsService.name); + private readonly _eventEmitter: EventEmitter2; + private readonly _dbContext: DatabaseContext; + + constructor( + eventEmitter: EventEmitter2, + @Inject(_$.IDatabaseContext) dbContext: DatabaseContext + ) { + this._eventEmitter = eventEmitter; + this._dbContext = dbContext; + } + + public async banUser(payload: ModerationPayloadDto): Promise { + const user = await this._dbContext.Users.findUserById(payload.id); + const moderator = await this._dbContext.Users.findUserById(payload.moderatorId); + + // If the moderator role's permissions are higher than the user's role's permissions, the moderator can ban the user. + if (Math.max(...moderator.roles) <= Math.max(...user.roles)) { + throw new HttpException("You cannot ban this user.", 403); + } + const banProps = new GotBannedProps({ + bannedAt: Date.now(), + moderatorId: payload.moderatorId, + reason: payload.reason, + }); + await this._dbContext.Users.banUser(payload.id, banProps); + return; + } + + public async unbanUser(userId: UUID): Promise { + const user = await this._dbContext.Users.findUserById(userId); + if (!user) { + throw new HttpException("User not found", 404); + } + + await user.getGotBannedProps(); + if (!user.gotBannedProps) { + throw new HttpException("User is not banned", 400); + } + + await this._dbContext.Users.addPreviouslyBanned(userId, user.gotBannedProps); + + await this._dbContext.Users.unbanUser(userId); + return; + } + + public async unrestrictComment(commentId: UUID): Promise { + const comment = await this.acquireComment(commentId); + + await comment.getRestricted(); + if (!comment.restrictedProps) { + return comment; + } + + await this._dbContext.Comments.unrestrictComment(commentId); + comment.restrictedProps = null; + + return comment; + } + + public async unrestrictPost(postId: UUID): Promise { + const post = await this.acquirePost(postId); + + await post.getRestricted(); + if (!post.restrictedProps) { + return post; + } + + await this._dbContext.Posts.unrestrictPost(postId); + post.restrictedProps = null; + + return post; + } + + public async deleteComment(payload: ModerationPayloadDto): Promise { + const comment = await this.acquireComment(payload.id); + + await comment.getDeletedProps(); + if (comment.deletedProps) { + return comment; + } + + const deletedProps = new DeletedProps({ + deletedAt: Date.now(), + moderatorId: payload.moderatorId, + reason: payload.reason, + }); + await this._dbContext.Comments.markAsDeleted(payload.id, deletedProps); + comment.deletedProps = deletedProps; + + return comment; + } + + public async deletePost(payload: ModerationPayloadDto): Promise { + const post = await this.acquirePost(payload.id); + + await post.getDeletedProps(); + if (post.deletedProps) { + return post; + } + + const deletedProps = new DeletedProps({ + deletedAt: Date.now(), + moderatorId: payload.moderatorId, + reason: payload.reason, + }); + await this._dbContext.Posts.markAsDeleted(payload.id, deletedProps); + post.deletedProps = deletedProps; + + return post; + } + + public async restrictComment(payload: ModerationPayloadDto): Promise { + const comment = await this.acquireComment(payload.id); + + await comment.getRestricted(); + if (comment.restrictedProps) { + return comment; + } + + const restrictedProps = new RestrictedProps({ + restrictedAt: Date.now(), + moderatorId: payload.moderatorId, + reason: payload.reason, + }); + await this._dbContext.Comments.restrictComment(payload.id, restrictedProps); + comment.restrictedProps = restrictedProps; + + try { + await comment.getAuthorUser(); + this._eventEmitter.emit( + EventTypes.CommentGotRestricted, + new CommentGotRestrictedEvent({ + subscriberUserId: comment.authorUser.userId, + commentContent: comment.commentContent, + reason: payload.reason, + username: comment.authorUser.username, + avatar: comment.authorUser.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } + + return comment; + } + + public async restrictPost(payload: ModerationPayloadDto): Promise { + const post = await this.acquirePost(payload.id); + + await post.getRestricted(); + if (post.restrictedProps) { + return post; + } + + const restrictedProps = new RestrictedProps({ + restrictedAt: Date.now(), + moderatorId: payload.moderatorId, + reason: payload.reason, + }); + await this._dbContext.Posts.restrictPost(payload.id, restrictedProps); + post.restrictedProps = restrictedProps; + + try { + await post.getAuthorUser(); + this._eventEmitter.emit( + EventTypes.PostGotRestricted, + new PostGotRestrictedEvent({ + subscriberId: post.authorUser.userId, + postTitle: post.postTitle, + reason: payload.reason, + username: post.authorUser.username, + avatar: post.authorUser.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } + + return post; + } + + public async restoreComment(commentId: UUID): Promise { + const comment = await this.acquireComment(commentId); + + await comment.getDeletedProps(); + if (!comment.deletedProps) { + return comment; + } + + await this._dbContext.Comments.removeDeletedMark(commentId); + comment.deletedProps = null; + + return comment; + } + + public async restorePost(postId: UUID): Promise { + const post = await this.acquirePost(postId); + + await post.getDeletedProps(); + if (!post.deletedProps) { + return post; + } + + await this._dbContext.Posts.removeDeletedMark(postId); + post.deletedProps = null; + + return post; + } + + public async allowComment(commentId: UUID): Promise { + const comment = await this.acquireComment(commentId); + + if (!comment.pending) { + return comment; + } + + comment.pending = false; + await this._dbContext.Comments.updateComment(comment); + + // don't wait + try { + await comment.getAuthorUser(); + const post = await this._dbContext.Comments.findParentPost(comment.commentId); + this._eventEmitter.emit( + EventTypes.CommentGotApprovedByModerator, + new CommentGotApprovedByModeratorEvent({ + subscriberId: comment.authorUser.userId, + commentId: comment.commentId, + postId: post.postId, + username: comment.authorUser.username, + avatar: comment.authorUser.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } + + return comment; + } + + public async allowPost(postId: UUID): Promise { + const post = await this.acquirePost(postId); + + if (!post.pending) { + return post; + } + + await this._dbContext.neo4jService.tryWriteAsync( + ` + MATCH (p:Post { postId: $postId }) + SET p.pending = false + `, + { + postId, + } + ); + post.pending = false; + + try { + await post.getAuthorUser(); + this._eventEmitter.emit( + EventTypes.PostGotApprovedByModerator, + new PostGotApprovedByModeratorEvent({ + subscriberId: post.authorUser.userId, + postId: post.postId, + username: post.authorUser.username, + avatar: post.authorUser.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } + return post; + } + + public async getPendingPosts(): Promise { + const posts = await this._dbContext.Posts.getPendingPosts(); + if (!posts) throw new HttpException("Posts not found", 404); + return posts; + } + + public async getDeletedPosts(): Promise { + const posts = await this._dbContext.Posts.getDeletedPosts(); + if (!posts) throw new HttpException("Posts not found", 404); + return posts; + } + + /** + * @description + * This method is to find a comment from the database and throw an error if it does not exist. if it does exist, it will return the comment. + * @param commentId + * @private + */ + private async acquireComment(commentId: UUID): Promise { + const comment = await this._dbContext.Comments.findCommentById(commentId); + if (!comment) { + throw new HttpException("Comment not found", 404); + } + return comment; + } + + /** + * @description + * This method is to find a post from the database and throw an error if it does not exist. if it does exist, it will return the post. + * @param postId + * @private + */ + private async acquirePost(postId: UUID): Promise { + const post = await this._dbContext.Posts.findPostById(postId); + if (!post) { + throw new HttpException("Post not found", 404); + } + return post; + } +} diff --git a/src/neo4j/neo4j.helper.types.ts b/src/neo4j/neo4j.helper.types.ts index fcf9b87..36f0fcb 100644 --- a/src/neo4j/neo4j.helper.types.ts +++ b/src/neo4j/neo4j.helper.types.ts @@ -1,5 +1,6 @@ import { Neo4jService } from "./services/neo4j.service"; import { Exclude } from "class-transformer"; +import { IsInstance } from "class-validator"; export class RelationshipProps {} @@ -26,6 +27,7 @@ export type RichRelatedEntities = { }; export class Model { + @IsInstance(Neo4jService) @Exclude() protected neo4jService: Neo4jService; diff --git a/src/neo4j/neo4j.module.ts b/src/neo4j/neo4j.module.ts index 7908d8b..8f1c369 100644 --- a/src/neo4j/neo4j.module.ts +++ b/src/neo4j/neo4j.module.ts @@ -1,12 +1,14 @@ -import { DynamicModule, Module, Provider } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module, Provider } from "@nestjs/common"; import { Neo4jService } from "./services/neo4j.service"; import { NEO4J_DRIVER, NEO4J_OPTIONS } from "./neo4j.constants"; import { createDriver } from "./neo4j.utils"; import { Neo4jConfig } from "./neo4jConfig.interface"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { Neo4jSeedService } from "./services/neo4j.seed.service"; +import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module"; @Module({ + imports: [forwardRef(() => DatabaseAccessLayerModule)], providers: [Neo4jService, Neo4jSeedService], }) export class Neo4jModule { diff --git a/src/neo4j/neo4j.utils.ts b/src/neo4j/neo4j.utils.ts index cb97f1e..818ca7c 100644 --- a/src/neo4j/neo4j.utils.ts +++ b/src/neo4j/neo4j.utils.ts @@ -20,3 +20,10 @@ export const createDriver = async (config: Neo4jConfig) => { // If everything is OK, return the driver return driver; }; + +export const fixNeo4jIntegers = (obj: any, propertyNames: string[]): any => { + for (const propertyName of propertyNames) { + obj[propertyName] = obj[propertyName]?.low ?? obj[propertyName]; + } + return obj; +}; diff --git a/src/neo4j/services/neo4j.seed.service.ts b/src/neo4j/services/neo4j.seed.service.ts index 0e96b97..9765db2 100644 --- a/src/neo4j/services/neo4j.seed.service.ts +++ b/src/neo4j/services/neo4j.seed.service.ts @@ -14,10 +14,15 @@ import { UserToSexualityRelTypes } from "../../users/models/toSexuality"; import { RestrictedProps, _ToSelfRelTypes } from "../../_domain/models/toSelf"; import { LABELS_DECORATOR_KEY } from "../neo4j.constants"; import { Neo4jService } from "./neo4j.service"; +import { _$ } from "../../_domain/injectableTokens"; +import { DatabaseContext } from "../../database-access-layer/databaseContext"; @Injectable() export class Neo4jSeedService { - constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} + constructor( + @Inject(Neo4jService) private _neo4jService: Neo4jService, + @Inject(_$.IDatabaseContext) private _dbContext: DatabaseContext + ) {} private postTypeLabel = Reflect.get(PostType, LABELS_DECORATOR_KEY)[0]; private postTagLabel = Reflect.get(PostTag, LABELS_DECORATOR_KEY)[0]; @@ -79,7 +84,7 @@ export class Neo4jSeedService { ); // Populate post types - const postTypes = await this.getPostTypes(); + const postTypes = Object.values(await this.getPostTypes()); for (const postTypeEntity of postTypes) { await this._neo4jService.tryWriteAsync( `CREATE (n:${this.postTypeLabel} { @@ -92,7 +97,7 @@ export class Neo4jSeedService { } // Populate post tags - const postTags = await this.getPostTags(); + const postTags = Object.values(await this.getPostTags()); for (const postTagEntity of postTags) { await this._neo4jService.tryWriteAsync( `CREATE (n:${this.postTagLabel} { @@ -132,28 +137,42 @@ export class Neo4jSeedService { // Populate genders const genders = await this.getGenders(); for (const genderEntity of genders) { - await this._neo4jService.tryWriteAsync( - `CREATE (n:${this.genderLabel} { - genderId: $genderId, - genderName: $genderName, - genderPronouns: $genderPronouns, - genderFlagSvg: $genderFlagSvg - })`, - genderEntity + const foundGenderEntity = await this._dbContext.Genders.findGenderById( + genderEntity.genderId ); + if (!foundGenderEntity) { + await this._neo4jService.tryWriteAsync( + `CREATE (n:${this.genderLabel} { + genderId: $genderId, + genderName: $genderName, + genderPronouns: $genderPronouns, + genderFlagSvg: $genderFlagSvg + })`, + genderEntity + ); + } else { + await this._dbContext.Genders.updateGender(genderEntity); + } } // Populate genders const opennessRecords = await this.getOpennessRecords(); for (const opennessEntity of opennessRecords) { - await this._neo4jService.tryWriteAsync( - `CREATE (n:${this.opennessLabel} { - opennessId: $opennessId, - opennessLevel: $opennessLevel, - opennessDescription: $opennessDescription - })`, - opennessEntity + const foundOpennessEntity = await this._dbContext.Openness.findOpennessById( + opennessEntity.opennessId ); + if (!foundOpennessEntity) { + await this._neo4jService.tryWriteAsync( + `CREATE (n:${this.opennessLabel} { + opennessId: $opennessId, + opennessLevel: $opennessLevel, + opennessDescription: $opennessDescription + })`, + opennessEntity + ); + } else { + await this._dbContext.Openness.updateOpenness(opennessEntity); + } } // Populate users @@ -181,12 +200,15 @@ export class Neo4jSeedService { email: $email, emailVerified: $emailVerified, + bio: $bio, + avatar: $avatar, + level: $level, roles: $roles - })-[:${UserToSexualityRelTypes.HAS_SEXUALITY}]->(s), - (u)-[:${UserToGenderRelTypes.HAS_GENDER}]->(g), - (u)-[:${UserToOpennessRelTypes.HAS_OPENNESS_LEVEL_OF}]->(o)`, + })-[:${UserToSexualityRelTypes.HAS_SEXUALITY} { isPrivate: $isSexualityPrivate }]->(s), + (u)-[:${UserToGenderRelTypes.HAS_GENDER} { isPrivate: $isGenderPrivate }]->(g), + (u)-[:${UserToOpennessRelTypes.HAS_OPENNESS_LEVEL_OF} { isPrivate: $isOpennessPrivate }]->(o)`, { userId: userEntity.userId, createdAt: userEntity.createdAt, @@ -198,12 +220,21 @@ export class Neo4jSeedService { phoneNumberVerified: userEntity.phoneNumberVerified, email: userEntity.email, emailVerified: userEntity.emailVerified, + + bio: userEntity.bio, + avatar: userEntity.avatar, + level: userEntity.level, roles: userEntity.roles, sexualityId: userEntity.sexuality.sexualityId, + isSexualityPrivate: userEntity.isSexualityPrivate, + genderId: userEntity.gender.genderId, + isGenderPrivate: userEntity.isGenderPrivate, + opennessId: userEntity.openness.opennessId, + isOpennessPrivate: userEntity.isOpennessPrivate, } ); } @@ -212,8 +243,8 @@ export class Neo4jSeedService { const posts = await this.getPosts(); const votesUserIds = [ - "5c0f145b-ffad-4881-8ee6-7647c3c1b695", - "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse + "a59437f4-ea62-4a15-a4e6-621b04af74d6", // verified - gabriel ]; for (const postEntity of posts) { @@ -233,7 +264,7 @@ export class Neo4jSeedService { } const authoredProps = new AuthoredProps({ - authoredAt: 1665770000, + authoredAt: postEntity.updatedAt, anonymously: false, }); await this._neo4jService.tryWriteAsync( @@ -262,18 +293,26 @@ export class Neo4jSeedService { }) WHERE postTag.tagName = postTagNameToBeConnected MERGE (p1)-[:${PostToPostTypeRelTypes.HAS_POST_TYPE}]->(postType) MERGE (p1)-[:${PostToPostTagRelTypes.HAS_POST_TAG}]->(postTag) - WITH [${postEntity.awards[PostToAwardRelTypes.HAS_AWARD].records - .map(record => `"${record.entity.awardId}"`) - .join(",")}] AS awardIDsToBeConnected - UNWIND awardIDsToBeConnected as awardIdToBeConnected - MATCH (p1:${this.postLabel}) WHERE p1.postId = $postId - MATCH (award:${this.awardLabel}) WHERE award.awardId = awardIdToBeConnected - MERGE (p1)-[:${PostToAwardRelTypes.HAS_AWARD} { awardedBy: "${ - ( - postEntity.awards[PostToAwardRelTypes.HAS_AWARD].records[0] - .relProps as HasAwardProps - ).awardedBy - }" } ]->(award) + ${ + postEntity.awards[PostToAwardRelTypes.HAS_AWARD].records.length > 0 + ? `WITH [${postEntity.awards[PostToAwardRelTypes.HAS_AWARD].records + .map(record => `"${record.entity.awardId}"`) + .join(",")}] AS awardIDsToBeConnected + UNWIND awardIDsToBeConnected as awardIdToBeConnected + MATCH (p1:${this.postLabel}) WHERE p1.postId = $postId + MATCH (award:${ + this.awardLabel + }) WHERE award.awardId = awardIdToBeConnected + MERGE (p1)-[:${ + PostToAwardRelTypes.HAS_AWARD + } { awardedBy: "${ + ( + postEntity.awards[PostToAwardRelTypes.HAS_AWARD] + .records[0].relProps as HasAwardProps + ).awardedBy + }" } ]->(award)` + : "" + } WITH [${votesUserIds .map(voterUserId => `"${voterUserId}"`) .join(",")}] AS voterUserIdsToBeConnected @@ -294,7 +333,7 @@ export class Neo4jSeedService { pending: postEntity.pending, // PostType - postTypeName: postEntity.postType.postTypeName.trim().toLowerCase(), + postTypeName: postEntity.postType.postTypeName, // AuthoredProps authoredProps_authoredAt: authoredProps.authoredAt, @@ -322,11 +361,12 @@ export class Neo4jSeedService { } as RestrictedProps; } const authoredProps = new AuthoredProps({ - authoredAt: commentEntity.createdAt, + authoredAt: commentEntity.updatedAt, anonymously: false, }); await this._neo4jService.tryWriteAsync( - `MATCH (authorUser:${this.userLabel}) WHERE authorUser.userId = $authorUserId + ` + MATCH (authorUser:${this.userLabel}) WHERE authorUser.userId = $authorUserId CREATE (comment:${this.commentLabel} { commentId: $commentId, commentContent: $commentContent, @@ -356,9 +396,11 @@ export class Neo4jSeedService { } ); + // Connect Comment to its parent (either a Post or another Comment) if (commentEntity.parentId !== null) { await this._neo4jService.tryWriteAsync( - `MATCH (comment:${this.commentLabel} { commentId: $commentId }) + ` + MATCH (comment:${this.commentLabel} { commentId: $commentId }) MATCH (parent) WHERE (parent:${this.postLabel} AND parent.postId = $parentId) OR (parent:${this.commentLabel} AND parent.commentId = $parentId) FOREACH (i in CASE WHEN parent:${this.postLabel} THEN [1] ELSE [] END | MERGE (comment)<-[:${PostToCommentRelTypes.HAS_COMMENT}]-(parent)) @@ -373,7 +415,8 @@ export class Neo4jSeedService { if (commentEntity.pinned) { await this._neo4jService.tryWriteAsync( - `MATCH (comment:${this.commentLabel} { commentId: $commentId }) + ` + MATCH (comment:${this.commentLabel} { commentId: $commentId }) MATCH (parent) WHERE parent:${this.postLabel} AND parent.postId = $parentId MERGE (comment)-[:${PostToCommentRelTypes.PINNED_COMMENT}]->(parent) `, @@ -398,7 +441,11 @@ export class Neo4jSeedService { } public async getUsers(): Promise { - const onlyAuthoredPosts = { + const authoredPosts = { + [UserToPostRelTypes.AUTHORED]: { + records: [], + relType: UserToPostRelTypes.AUTHORED, + }, [UserToPostRelTypes.READ]: { records: [], relType: UserToPostRelTypes.READ, @@ -423,168 +470,280 @@ export class Neo4jSeedService { return new Array( new User({ - userId: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", + userId: "71120d45-7a75-43fd-b79c-54b06e7868af", // verified + createdAt: new Date().getTime(), + updatedAt: new Date().getTime(), + avatar: "(づ ̄ 3 ̄)づ", + bio: "My name is Wesley.", + username: "wesley", + normalizedUsername: "WESLEY", + passwordHash: "", + phoneNumber: null, + phoneNumberVerified: false, + email: "wesley@domain.com", + emailVerified: false, + level: 0, + roles: [Role.USER, Role.MODERATOR], + gender: new Gender({ + genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", // verified - Male - He/Him + }), + isGenderPrivate: false, + sexuality: new Sexuality({ + sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", // verified - Gay + }), + isSexualityPrivate: false, + openness: new Openness({ + opennessId: "db27c417-a8a5-4703-9b35-9dc76e98fc95", // verified - Out to Few + }), + isOpennessPrivate: false, + posts: { ...authoredPosts }, + }), + new User({ + userId: "5e520efd-f78e-4cb0-8903-5c99197d4b8e", // verified + createdAt: new Date().getTime(), + updatedAt: new Date().getTime(), + avatar: "...(* ̄0 ̄)ノ", + bio: "My name is Gaius.", + username: "gaius", + normalizedUsername: "GAIUS", + passwordHash: "", + phoneNumber: null, + phoneNumberVerified: false, + email: "gaius@rome.it", + emailVerified: false, + level: 0, + roles: [Role.USER], + gender: new Gender({ + genderId: "3af72545-99d4-4715-812b-c935fbf57f22", // verified - ve/ver + }), + isGenderPrivate: false, + sexuality: new Sexuality({ + sexualityId: "5bc9535e-cc50-4112-91ad-717dc2de9492", // verified - Bisexual + }), + isSexualityPrivate: false, + openness: new Openness({ + opennessId: "842b5bd7-1da1-4a95-9564-1fc3b97b3655", // verified - Not Out + }), + isOpennessPrivate: false, + posts: { ...authoredPosts }, + }), + new User({ + userId: "a59437f4-ea62-4a15-a4e6-621b04af74d6", // verified createdAt: new Date().getTime(), updatedAt: new Date().getTime(), avatar: ":^)", - username: "alice", - normalizedUsername: "ALICE", - passwordHash: "password", + bio: "My name is gabriel.", + username: "gabriel", + normalizedUsername: "GABRIEL", + passwordHash: "", phoneNumber: null, phoneNumberVerified: false, - email: "a@a.com", + email: "gabriel@domain.com", emailVerified: false, level: 0, - roles: [Role.MODERATOR], + roles: [Role.USER], gender: new Gender({ - genderId: "d2945763-d1fb-46aa-b896-7f701b4ca699", + genderId: "f97edcdf-9df4-4f4a-9114-fbcd702502af", // verified - NA }), + isGenderPrivate: false, sexuality: new Sexuality({ - sexualityId: "1b67cf76-752d-4ea5-9584-a4232998b838", + sexualityId: "2d32c4d3-4aca-4b03-bf68-ba104656183f", // verified - Asexual }), + isSexualityPrivate: false, openness: new Openness({ - opennessId: "d5c97584-cd1b-4aa6-82ad-b5ddd3577bee", + opennessId: "ae90b960-5f00-4298-b509-fac92a59b406", // verified - Not Sure }), - posts: { - [UserToPostRelTypes.AUTHORED]: { - records: (await this.getPosts()).slice(0, 2).map(post => ({ - entity: post, - relProps: new AuthoredProps({ - authoredAt: new Date("June 1st, 2022").getTime(), - anonymously: false, - }), - })), - relType: UserToPostRelTypes.AUTHORED, - }, - ...onlyAuthoredPosts, - }, + isOpennessPrivate: false, + posts: { ...authoredPosts }, }), new User({ - userId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified createdAt: new Date().getTime(), updatedAt: new Date().getTime(), avatar: "^_^", - username: "leo", - normalizedUsername: "LEO", - passwordHash: "123", + bio: "My name is alphonse.", + username: "alphonse", + normalizedUsername: "ALPHONSE", + passwordHash: "", phoneNumber: null, phoneNumberVerified: false, - email: "b@b.com", + email: "admin@domain.com", emailVerified: false, level: 0, - roles: [Role.USER, Role.MODERATOR], + roles: [Role.USER], gender: new Gender({ - genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", + genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", // verified - Male - He/Him }), + isGenderPrivate: false, sexuality: new Sexuality({ - sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", + sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", // verified - Gay }), + isSexualityPrivate: false, openness: new Openness({ - opennessId: "ae90b960-5f00-4298-b509-fac92a59b406", + opennessId: "d5c97584-cd1b-4aa6-82ad-b5ddd3577bee", // verified - Fully Out }), - posts: { - [UserToPostRelTypes.AUTHORED]: { - records: (await this.getPosts()).slice(2).map(post => ({ - entity: post, - relProps: new AuthoredProps({ - authoredAt: new Date("June 1st, 2022").getTime(), - anonymously: false, - }), - })), - relType: UserToPostRelTypes.AUTHORED, - }, - ...onlyAuthoredPosts, - }, + isOpennessPrivate: false, + posts: { ...authoredPosts }, }), new User({ - userId: "8f0c1ecf-6853-4642-9199-6e8244b89312", + userId: "0daef999-7291-4f0c-a41a-078a6f28aa5e", // verified createdAt: new Date().getTime(), updatedAt: new Date().getTime(), - avatar: "🤠", - username: "ilia", - normalizedUsername: "ILIA", - passwordHash: "$2b$10$nBR48Qq3e27FWfO3Yxezseaz7GRDe9qo4wXnwT7XoaDnCx9.Id7x6", + avatar: "^_^", + bio: "My name is christopher.", + username: "christopher", + normalizedUsername: "CHRISTOPHER", + passwordHash: "", phoneNumber: null, phoneNumberVerified: false, - email: "b@b.com", - emailVerified: true, - level: 3, - roles: [Role.ADMIN, Role.MODERATOR], + email: "christopher@domain.com", + emailVerified: false, + level: 0, + roles: [Role.USER, Role.MODERATOR], gender: new Gender({ - genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", + genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", // verified - Male - He/Him }), + isGenderPrivate: true, sexuality: new Sexuality({ - sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", + sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", // verified - Gay }), + isSexualityPrivate: true, openness: new Openness({ - opennessId: "ae90b960-5f00-4298-b509-fac92a59b406", + opennessId: "d5c97584-cd1b-4aa6-82ad-b5ddd3577bee", // verified - Fully Out }), - posts: { - [UserToPostRelTypes.AUTHORED]: { - records: [], - relType: UserToPostRelTypes.AUTHORED, - }, - ...onlyAuthoredPosts, - }, + isOpennessPrivate: false, + posts: { ...authoredPosts }, }) ); } public async getPosts(): Promise { + const postTags = await this.getPostTags(); + const postTypes = await this.getPostTypes(); return new Array( new Post({ - postId: "b73edbf4-ba84-4b11-a91c-e1d8b1366974", - postTitle: "Am I Lesbian?", - postContent: "today I kissed a girl! it felt so good.", - updatedAt: 1665770000, - postType: (await this.getPostTypes())[0], - postTags: (await this.getPostTags()).slice(0, 2), + postId: "bcddeb57-939d-441b-b4ea-71e1d2055f32", // verified + postTitle: "Sister caught me checking out a guy on a camping trip", + postContent: + "I was on a camping trip and my sister caught me staring at someone across the site with his \n" + + " shirt off, for the the rest of the day she wouldn't stop asking me, even getting the other members \n" + + " who came with us to join in, I eventually gave in, she was super kind about it and came out as Bisexual the following months", + updatedAt: new Date("2022-11-17").getTime(), // verified - NOTE: will be counted as `authoredAt` value. + postType: postTypes.story, // verified - story + postTags: [postTags.Casual, postTags.General, postTags.Trigger], // verified restrictedProps: null, authorUser: new User({ - userId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + userId: "a59437f4-ea62-4a15-a4e6-621b04af74d6", // verified - gabriel }), pending: false, - totalVotes: 1, + totalVotes: 0, awards: { [PostToAwardRelTypes.HAS_AWARD]: { - records: (await this.getAwards()).slice(0, 2).map(award => ({ - entity: award, - relProps: new HasAwardProps({ - awardedBy: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", - }), - })), + records: [], relType: PostToAwardRelTypes.HAS_AWARD, }, }, }), new Post({ - postId: "596632ac-dd54-4700-a783-688618d99fa9", - postTitle: "Am I Gay?", - postContent: "today I kissed a boy! it felt so good.", - updatedAt: 1665770000, - postType: (await this.getPostTypes())[0], - postTags: (await this.getPostTags()).slice(0, 2), + postId: "be9ab5e4-eb7c-469b-a1e3-592dca2a00d0", // verified + postTitle: "Coming out to my accepting family", + postContent: + "Growing up my family was really gay friendly. I had two gay uncles and everyone was accepting of them. \n " + + "When I was in fifth grade my mom told me that she would love me no matter who I loved. At the time I had \n " + + "no idea what she meant because all I cared about was playing soccer. However, as I got older I started \n " + + "to comprehend what my mother's words really meant. In middle school all my friends talked about boys and \n " + + "tried getting boyfriends, but I wasn't interested. That's when I started to realize that I was different from all my friends. \n\n " + + "During my first year of high school I realized I was a lesbian and it felt good to know that my mother was there to support me. \n " + + "At the end of my first year of high school I began dating my best friend, but that relationship only lasted three months before my \n " + + "girlfriend started cheating on me with my cousin. I hadn't openly come out to my family yet so I tried to keep the relationship and \n " + + "breakup a secret. One day one of my sisters saw me crying and she asked me what was wrong. I told her everything that had been going \n " + + "on between my cousin, my ex-girlfriend, and me. Instead of addressing the fact that I had dated a girl, my sister was mad about what \n " + + "my cousin had done. My sister told everyone in my family what had happened and everyone was upset about what my cousin had done. \n " + + "No one in my family was upset about the fact that my cousin and I were gay. \n\n " + + "Today my entire family knows that I am gay and they accept me. It is nice to have such an accepting family and I know that I am \n " + + "very fortunate to have a family that loves me unconditionally. I am grateful that my family has never judged me or made me feel \n " + + "uncomfortable expressing who I am.", + updatedAt: new Date("2022-11-20").getTime(), // verified - NOTE: will be counted as `authoredAt` value. + postType: postTypes.story, // verified - story + postTags: [postTags.Serious], // verified - Serious restrictedProps: new RestrictedProps({ - restrictedAt: 1665780000, - moderatorId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + restrictedAt: new Date("2022-11-22").getTime(), + moderatorId: "71120d45-7a75-43fd-b79c-54b06e7868af", // verified - moderator - wesley reason: "The moderator thinks there is profanity in this post", }), authorUser: new User({ - userId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse }), pending: true, totalVotes: 3, awards: { [PostToAwardRelTypes.HAS_AWARD]: { - records: (await this.getAwards()).slice(0, 2).map(award => ({ + records: (await this.getAwards()).slice(-1, 1).map(award => ({ entity: award, relProps: new HasAwardProps({ - awardedBy: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", + awardedBy: "0daef999-7291-4f0c-a41a-078a6f28aa5e", // verified - moderator - christopher }), })), relType: PostToAwardRelTypes.HAS_AWARD, }, }, + }), + new Post({ + postId: "806ca5f3-f80c-47fc-9e4d-00434dd18358", // verified + postTitle: "Coming out to my brother", + postContent: `When I was 17 my girlfriend was over my house and my brother was home from college. + She asks me "does your brother know we're dating?"I say "good question." Then I yell to the other + side of the house "[Brother]! Did you know that I'm dating [girlfriend]?" I think he said + something like "I do now!"`, + updatedAt: new Date("2022-09-01").getTime(), // verified - NOTE: will be counted as `authoredAt` value. + postType: postTypes.story, // verified - story + postTags: [postTags.Casual], // verified - Casual + restrictedProps: null, + authorUser: new User({ + userId: "71120d45-7a75-43fd-b79c-54b06e7868af", // verified - wesley - moderator + }), + pending: false, + totalVotes: 0, + awards: { + [PostToAwardRelTypes.HAS_AWARD]: { + records: [], + relType: PostToAwardRelTypes.HAS_AWARD, + }, + }, + }), + new Post({ + postId: "6326079f-fd2f-4b81-83fe-487daee459bc", // verified + postTitle: "Coming out as GNC on my birthday", + postContent: `This is the uncomfortable story of how I came out to my mom and younger brother (with whom I live). + \n\nLeading up to my birthday last year I had realized I'm gnc (gender nonconforming) and had been starting to feel + very uncomfortable with masculine pronouns. I was already looking for a replacement name at the time and knew I + needed to do something about the way I was being referred to for my sanity. I've never been particularly fond of + holidays (my birthday especially) because something always seems to go awry when family is involved. None the less + I decided to take control of the situation and hope for the best. \n\nI custom ordered my cake and bought all the + ingredients for the meal and cooked it myself. I even did my makeup. Not something I do often, but it was my birthday + and I wanted to feel pretty. Before unveiling the cake (decorated with a nonbinary flag) I went through the whole + spiel about being nb and what that meant for me, as well as explaining pronouns and what not. \n\nWhen I finished they + were silent for a minute or two. My brother spoke first, saying something to the tune of: My dad says gnc people go + to hell. Obviously not the first thing you want to hear after such a tense interaction. Long story short dinner resolved + peacefully but it didn't seem I got through to them as the next couple weeks were filled with pronoun related arguments. + \n\nMuch headbutting later they've come around on my pronouns and the name I've chosen. As you can imagine though, for a + time I felt extremely unwelcome in the home, and after all the effort I put in I was pretty devastated my coming out + transpired so poorly.`, + updatedAt: new Date("2022-05-26").getTime(), // verified - NOTE: will be counted as `authoredAt` value. + postType: postTypes.story, // verified - story + postTags: [postTags.Serious], // verified - Serious + restrictedProps: null, + authorUser: new User({ + userId: "5e520efd-f78e-4cb0-8903-5c99197d4b8e", // verified - gaius + }), + pending: false, + totalVotes: 0, + awards: { + [PostToAwardRelTypes.HAS_AWARD]: { + records: [], + relType: PostToAwardRelTypes.HAS_AWARD, + }, + }, }) ); } @@ -592,62 +751,83 @@ export class Neo4jSeedService { public async getComments(): Promise { return new Array( new Comment({ - commentId: "37fbb7c9-013f-4057-bc90-f38498b69295", - parentId: "596632ac-dd54-4700-a783-688618d99fa9", - commentContent: "I think this post is great!", - createdAt: 1665770000, - updatedAt: 1665770000, + commentId: "f8959b32-5b68-4f68-97bc-59afdc0d09cb", // verified - no children + parentId: "bcddeb57-939d-441b-b4ea-71e1d2055f32", // verified - Title: Sister caught me checking out a guy on a camping trip. + commentContent: "Wow that's so nice to hear!", + updatedAt: new Date("2022-11-18").getTime(), // verified - a day after Post authored authorUser: new User({ - userId: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", + userId: "0daef999-7291-4f0c-a41a-078a6f28aa5e", // verified - moderator - christopher }), - pinned: true, + pinned: true, // verified pending: false, restrictedProps: null, childComments: [], }), new Comment({ - commentId: "13cc9fd9-4c99-4daa-bf17-750bd1efa5d8", - parentId: "596632ac-dd54-4700-a783-688618d99fa9", - commentContent: "First comment! :yay:", - createdAt: 1665770000, - updatedAt: 1665770000, + commentId: "c13c4349-bbf9-45a7-a573-7a04efa66e3c", // verified + parentId: "be9ab5e4-eb7c-469b-a1e3-592dca2a00d0", // verified - Title: Coming out to my accepting family + commentContent: `Congratulations! That's so nice you have such a supportive family. + Sorry to hear about your ex-girlfriend and your cousin though. I hope you're doing better now.`, + updatedAt: new Date("2022-11-21").getTime(), // verified - a day after post authored authorUser: new User({ - userId: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", + userId: "a59437f4-ea62-4a15-a4e6-621b04af74d6", // verified - gabriel }), pinned: false, pending: true, - restrictedProps: new RestrictedProps({ - restrictedAt: 1665780000, - moderatorId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", - reason: "The moderator thinks there is profanity in this comment", - }), + restrictedProps: null, childComments: [ new Comment({ - commentId: "04658465-f9ea-427b-9f5b-ed5e93db27ff", - parentId: "13cc9fd9-4c99-4daa-bf17-750bd1efa5d8", - commentContent: "Second comment! :yay:", - createdAt: 1665770000, - updatedAt: 1665770000, + commentId: "3ee2801a-998d-437a-a49e-3974919f35c1", // verified + parentId: "c13c4349-bbf9-45a7-a573-7a04efa66e3c", // verified + commentContent: `Wow that's so scummy of your cousin to do that to you! I would have never forgiven her.`, + updatedAt: new Date("2022-11-22").getTime(), // verified - a day after parent authored authorUser: new User({ - userId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse }), pinned: false, pending: true, - restrictedProps: new RestrictedProps({ - restrictedAt: 1665780000, - moderatorId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", - reason: "The moderator died of cringe", - }), - childComments: [], + restrictedProps: null, + childComments: [ + new Comment({ + commentId: "287ca219-005e-41fa-af40-abf59c2c2caf", // verified + parentId: "3ee2801a-998d-437a-a49e-3974919f35c1", // verified + commentContent: `I agree! I would have never forgiven her either.`, + updatedAt: new Date("2022-11-23").getTime(), // verified - a day after parent comment authored + authorUser: new User({ + userId: "0daef999-7291-4f0c-a41a-078a6f28aa5e", // verified - christopher + }), + pinned: false, + pending: true, + restrictedProps: new RestrictedProps({ + restrictedAt: new Date("2022-11-23").getTime(), + moderatorId: "71120d45-7a75-43fd-b79c-54b06e7868af", // verified - moderator - wesley + reason: "The moderator died of cringe", + }), + childComments: [], + }), + ], }), + ], + }), + new Comment({ + commentId: "5a11c2af-7716-4b67-b00f-e23df9f0c740", // verified + parentId: "806ca5f3-f80c-47fc-9e4d-00434dd18358", // verified - top-level comment - Post Title: Coming out to my brother + commentContent: `That story is so funny! What a supportive brother.`, + updatedAt: new Date("2022-09-03").getTime(), // verified - 2 days after post authored + authorUser: new User({ + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse + }), + pinned: false, + pending: false, + restrictedProps: null, + childComments: [ new Comment({ - commentId: "acba2871-2435-4439-82c3-adebc7cdc942", - parentId: "13cc9fd9-4c99-4daa-bf17-750bd1efa5d8", - commentContent: "First-Second comment! :yay:", - createdAt: 1665770000, - updatedAt: 1665770000, + commentId: "9e55090e-2ebf-4679-a912-6542e78f4905", // verified + parentId: "5a11c2af-7716-4b67-b00f-e23df9f0c740", // verified + commentContent: `Same! I wish my brother was like that.`, + updatedAt: new Date("2022-09-04").getTime(), authorUser: new User({ - userId: "3109f9e2-a262-4aef-b648-90d86d6fbf6c", + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse }), pinned: false, pending: false, @@ -655,60 +835,117 @@ export class Neo4jSeedService { childComments: [], }), ], + }), + new Comment({ + commentId: "773c1b6d-9d0a-43cb-94e5-2da1bac633c0", // verified + parentId: "6326079f-fd2f-4b81-83fe-487daee459bc", // verified - top-level comment - Post Title: Coming out as GNC on my birthday + commentContent: + "Wow I can't even imagine how hard that must have been. Thankfully your family now understands what you're going through.", + updatedAt: new Date("2022-05-27").getTime(), // verified - a day after the post was authored + authorUser: new User({ + userId: "71120d45-7a75-43fd-b79c-54b06e7868af", // verified - wesley + }), + pinned: false, + pending: false, + restrictedProps: null, + childComments: [], + }), + new Comment({ + commentId: "84213582-a148-46b7-878d-c30a9cd02231", // verified + parentId: "6326079f-fd2f-4b81-83fe-487daee459bc", // verified - top-level comment - Post Title: Coming out as GNC on my birthday + commentContent: "You dad sounds exhausting. I'm glad you're doing better now.", + updatedAt: new Date("2022-05-27").getTime(), // verified - a day after the post was authored + authorUser: new User({ + userId: "dc83daa3-d26b-4063-87b1-2b719069654e", // verified - alphonse + }), + pinned: false, + pending: false, + restrictedProps: null, + childComments: [], }) ); } - public async getPostTypes(): Promise { - return new Array( - new PostType({ + public async getPostTypes(): Promise> { + return { + queery: new PostType({ postTypeName: "queery", }), - new PostType({ + story: new PostType({ postTypeName: "story", - }) - ); + }), + }; } - public async getPostTags(): Promise { - return new Array( - new PostTag({ - tagName: "Serious", - tagColor: "#FF758C", + public async getPostTags(): Promise< + Record< + | "Serious" + | "Advice" + | "Discussion" + | "Trigger" + | "General" + | "Casual" + | "Inspiring" + | "Vent" + | "Drama", + PostTag + > + > { + return { + Serious: new PostTag({ + tagName: "serious", + tagColor: "#E02947", }), - new PostTag({ - tagName: "Advice", - tagColor: "#FF758C", + Advice: new PostTag({ + tagName: "advice", + tagColor: "#FFB6C3", }), - new PostTag({ - tagName: "Discussion", - tagColor: "#FF758C", + Discussion: new PostTag({ + tagName: "discussion", + tagColor: "#FFB6C3", }), - new PostTag({ - tagName: "Trigger", - tagColor: "#FF758C", + Trigger: new PostTag({ + tagName: "trigger", + tagColor: "#C2ADFF", }), - new PostTag({ - tagName: "General", - tagColor: "#FF758C", + General: new PostTag({ + tagName: "general", + tagColor: "#FFB6C3", }), - new PostTag({ - tagName: "Casual", - tagColor: "#FF758C", - }) - ); + Casual: new PostTag({ + tagName: "casual", + tagColor: "#FFEAD4", + }), + Inspiring: new PostTag({ + tagName: "inspiring", + tagColor: "#C2ADFF", + }), + Vent: new PostTag({ + tagName: "vent", + tagColor: "#C2ADFF", + }), + Drama: new PostTag({ + tagName: "drama", + tagColor: "#C2ADFF", + }), + }; } public async getAwards(): Promise { return new Array( new Award({ - awardId: "2049221e-1f45-4430-8edc-95db808db072", - awardName: "Sean's Mom Award", + awardId: "032930d2-9994-46cc-ad35-559bb41a9d05", + awardName: "Saddest Story Award", awardSvg: "", }), new Award({ awardId: "375608ce-ca65-4293-8402-da34cd2c42c7", - awardName: "", + awardName: "Quality Queery Award", + awardSvg: "", + }), + new Award({ + awardId: "bf99f8f5-66f7-41ce-8014-5f70e5145174", + awardName: "Best Ally Award", awardSvg: "", }) ); @@ -717,24 +954,29 @@ export class Neo4jSeedService { public async getSexualities(): Promise { return new Array( new Sexuality({ - sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", + sexualityId: "9164d89b-8d71-4fd1-af61-155d1d7ffe53", // verified sexualityName: "Gay", sexualityFlagSvg: "", }), new Sexuality({ - sexualityId: "1b67cf76-752d-4ea5-9584-a4232998b838", + sexualityId: "1b67cf76-752d-4ea5-9584-a4232998b838", // verified sexualityName: "Lesbian", sexualityFlagSvg: "", }), new Sexuality({ - sexualityId: "df388311-c184-4f09-93f4-645c6175322c", + sexualityId: "df388311-c184-4f09-93f4-645c6175322c", // verified sexualityName: "Homosexual", sexualityFlagSvg: "", }), new Sexuality({ - sexualityId: "2d32c4d3-4aca-4b03-bf68-ba104656183f", + sexualityId: "2d32c4d3-4aca-4b03-bf68-ba104656183f", // verified sexualityName: "Asexual", sexualityFlagSvg: "", + }), + new Sexuality({ + sexualityId: "5bc9535e-cc50-4112-91ad-717dc2de9492", // verified + sexualityName: "Bisexual", + sexualityFlagSvg: "", }) ); } @@ -742,22 +984,46 @@ export class Neo4jSeedService { public async getGenders(): Promise { return new Array( new Gender({ - genderId: "d2945763-d1fb-46aa-b896-7f701b4ca699", + genderId: "f97edcdf-9df4-4f4a-9114-fbcd702502af", // verified + genderName: "NA", + genderPronouns: "NA", + genderFlagSvg: ``, + }), + new Gender({ + genderId: "7351c1c9-50cd-4871-9af0-60c8f99a4627", // verified genderName: "Female", genderPronouns: "She/Her", genderFlagSvg: "", }), new Gender({ - genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", + genderId: "585d31aa-d5b3-4b8d-9690-ffcd57ce2862", // verified genderName: "Male", genderPronouns: "He/Him", - genderFlagSvg: "", + genderFlagSvg: ``, }), new Gender({ - genderId: "23907da4-c3f2-4e96-a73d-423e64f18a21", + genderId: "23907da4-c3f2-4e96-a73d-423e64f18a21", // verified genderName: "Non-binary", genderPronouns: "They/Them", genderFlagSvg: "", + }), + new Gender({ + genderId: "3af72545-99d4-4715-812b-c935fbf57f22", // verified + genderName: "Non-binary", + genderPronouns: "Ve/Ver", + genderFlagSvg: "", + }), + new Gender({ + genderId: "6d67e992-c6d1-45be-8316-7a839894bf36", // verified + genderName: "Non-binary", + genderPronouns: "Xe/Xem", + genderFlagSvg: "", + }), + new Gender({ + genderId: "16c10474-9fa6-4eac-aac2-a63423edb757", // verified + genderName: "Non-binary", + genderPronouns: "Ze/Zie", + genderFlagSvg: "", }) ); } @@ -765,29 +1031,34 @@ export class Neo4jSeedService { public async getOpennessRecords(): Promise { return new Array( new Openness({ - opennessId: "ae90b960-5f00-4298-b509-fac92a59b406", + opennessId: "ae90b960-5f00-4298-b509-fac92a59b406", // verified opennessLevel: -1, opennessDescription: "Not Sure", }), new Openness({ - opennessId: "842b5bd7-1da1-4a95-9564-1fc3b97b3655", + opennessId: "842b5bd7-1da1-4a95-9564-1fc3b97b3655", // verified opennessLevel: 0, opennessDescription: "Not Out", }), new Openness({ - opennessId: "db27c417-a8a5-4703-9b35-9dc76e98fc95", + opennessId: "db27c417-a8a5-4703-9b35-9dc76e98fc95", // verified opennessLevel: 1, opennessDescription: "Out to Few", }), new Openness({ - opennessId: "822b2622-70d6-4d7c-860a-f56e309fe950", + opennessId: "822b2622-70d6-4d7c-860a-f56e309fe950", // verified opennessLevel: 2, opennessDescription: "Semi-Out", }), new Openness({ - opennessId: "d5c97584-cd1b-4aa6-82ad-b5ddd3577bee", + opennessId: "d5c97584-cd1b-4aa6-82ad-b5ddd3577bee", // verified opennessLevel: 3, opennessDescription: "Fully Out", + }), + new Openness({ + opennessId: "77ec4978-6775-4b2f-9e91-ea60bb3742a5", // verified + opennessLevel: 4, + opennessDescription: "Ally", }) ); } diff --git a/src/neo4j/services/neo4j.service.ts b/src/neo4j/services/neo4j.service.ts index dfb5e93..bd4659d 100644 --- a/src/neo4j/services/neo4j.service.ts +++ b/src/neo4j/services/neo4j.service.ts @@ -54,6 +54,7 @@ export class Neo4jService { return await session.run(cypher, params); } catch (error) { this._logger.debug(error); + } finally { await session.close(); } } @@ -69,6 +70,7 @@ export class Neo4jService { return await session.run(cypher, params); } catch (error) { this._logger.debug(error); + } finally { await session.close(); } } diff --git a/src/posts/controllers/posts.controller.ts b/src/posts/controllers/posts.controller.ts index c835777..5808ad4 100644 --- a/src/posts/controllers/posts.controller.ts +++ b/src/posts/controllers/posts.controller.ts @@ -4,8 +4,8 @@ import { CacheTTL, ClassSerializerInterceptor, Controller, + Delete, Get, - HttpException, Inject, Param, ParseUUIDPipe, @@ -15,11 +15,21 @@ import { } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { AuthedUser } from "../../auth/decorators/authedUser.param.decorator"; +import { Roles } from "../../auth/decorators/roles.decorator"; +import { OptionalJwtAuthGuard } from "../../auth/guards/optionalJwtAuth.guard"; +import { RolesGuard } from "../../auth/guards/roles.guard"; +import { Comment } from "../../comments/models"; import { DatabaseContext } from "../../database-access-layer/databaseContext"; +import { ModerationPayloadDto } from "../../moderation/dtos/moderatorActions"; +import { IModeratorActionsService } from "../../moderation/services/moderatorActions/moderatorActions.service.interface"; +import { Role, User } from "../../users/models"; import { _$ } from "../../_domain/injectableTokens"; +import { PostCreationPayloadDto, ReportPostPayloadDto, VotePostPayloadDto } from "../dtos"; import { Post as PostModel } from "../models"; -import { PostCreationPayloadDto } from "../dtos"; import { IPostsService } from "../services/posts/posts.service.interface"; +import { IPostsReportService } from "../services/postReport/postsReport.service.interface"; +import { CaptchaGuard } from "../../google-cloud-recaptcha-enterprise/captcha.guard"; @ApiTags("posts") @Controller("posts") @@ -28,61 +38,147 @@ import { IPostsService } from "../services/posts/posts.service.interface"; export class PostsController { private readonly _dbContext: DatabaseContext; private readonly _postsService: IPostsService; + private readonly _moderationActionsService: IModeratorActionsService; + private readonly _postsReportService: IPostsReportService; constructor( @Inject(_$.IDatabaseContext) dbContext: DatabaseContext, - @Inject(_$.IPostsService) postsService: IPostsService + @Inject(_$.IPostsService) postsService: IPostsService, + @Inject(_$.IModeratorActionsService) moderationActionsService: IModeratorActionsService, + @Inject(_$.IPostsReportService) postsReportService: IPostsReportService ) { this._dbContext = dbContext; this._postsService = postsService; + this._moderationActionsService = moderationActionsService; + this._postsReportService = postsReportService; } @Get() - @CacheTTL(10) + @Roles(Role.MODERATOR) + @UseGuards(OptionalJwtAuthGuard, RolesGuard) + @CacheTTL(5) @UseInterceptors(CacheInterceptor) - public async index(): Promise { + public async index(@AuthedUser() user: User): Promise { const posts = await this._dbContext.Posts.findAll(); - const decoratedPosts = posts.map(post => post.toJSON()); + const decoratedPosts = posts.map(post => + post.toJSON({ authenticatedUserId: user?.userId ?? undefined }) + ); return await Promise.all(decoratedPosts); } + @Get("/queery/ofTheDay") + @UseGuards(OptionalJwtAuthGuard) + @CacheTTL(3600 * 24) + @UseInterceptors(CacheInterceptor) + public async getQueeriesOfTheDay(): Promise { + return await this._postsService.getQueeriesOfTheDay(); + } + + @Get("/story/ofTheDay") + @UseGuards(OptionalJwtAuthGuard) + @CacheTTL(3600 * 24) + @UseInterceptors(CacheInterceptor) + public async getStoriesOfTheDay(): Promise { + return await this._postsService.getStoriesOfTheDay(); + } + @Get("/queery") + @UseGuards(OptionalJwtAuthGuard) @CacheTTL(10) @UseInterceptors(CacheInterceptor) - public async getAllQueeries(): Promise { - const queeries = await this._postsService.findAllQueeries( - (postA, postB) => postB.createdAt - postA.createdAt + public async getAllQueeries(@AuthedUser() user: User): Promise { + const queeries = await this._postsService.findAllQueeries(); + const decoratedQueeries = queeries.map(queery => + queery.toJSON({ authenticatedUserId: user?.userId ?? undefined }) ); - const decoratedQueeries = queeries.map(queery => queery.toJSON()); return await Promise.all(decoratedQueeries); } @Get("/story") + @UseGuards(OptionalJwtAuthGuard) @CacheTTL(10) @UseInterceptors(CacheInterceptor) - public async getAllStories(): Promise { - const stories = await this._postsService.findAllStories( - (postA, postB) => postB.createdAt - postA.createdAt + public async getAllStories(@AuthedUser() user: User): Promise { + const stories = await this._postsService.findAllStories(); + const decoratedStories = stories.map(story => + story.toJSON({ authenticatedUserId: user?.userId ?? undefined }) ); - const decoratedStories = stories.map(story => story.toJSON()); return await Promise.all(decoratedStories); } - @Get(":postId") + @Get("/:postId/nestedComments") + @UseGuards(OptionalJwtAuthGuard) + public async getNestedCommentsByPostId( + @AuthedUser() user: User, + @Param("postId", new ParseUUIDPipe()) postId: UUID + ): Promise { + const topLevelComments = await this._postsService.findNestedCommentsByPostId( + postId, + 10, + 2, + 2 + ); + const decoratedTopLevelComments = topLevelComments.map(comment => + comment.toJSONNested({ authenticatedUserId: user?.userId ?? undefined }) + ); + return await Promise.all(decoratedTopLevelComments); + } + + @Get("/:postId") + @CacheTTL(10) + @UseGuards(OptionalJwtAuthGuard) public async getPostById( - @Param("postId", new ParseUUIDPipe()) postId: string - ): Promise { - const post = await this._dbContext.Posts.findPostById(postId); - if (post === undefined) throw new HttpException("Post not found", 404); - return await post.toJSON(); + @AuthedUser() user: User, + @Param("postId", new ParseUUIDPipe()) postId: UUID + ): Promise { + const post = await this._postsService.findPostById(postId); + return await post.toJSON({ authenticatedUserId: user?.userId ?? undefined }); } - @Post("create") + @Get("/user/:userId") + @CacheTTL(20) @UseGuards(AuthGuard("jwt")) - public async createPost( - @Body() postPayload: PostCreationPayloadDto - ): Promise { + public async getPostsByUserId( + @AuthedUser() user: User, + @Param("userId", new ParseUUIDPipe()) userId: UUID + ): Promise { + const posts = await this._postsService.findPostsByUserId(userId); + const decoratedPosts = posts.map(post => + post.toJSON({ authenticatedUserId: user?.userId ?? undefined }) + ); + return await Promise.all(decoratedPosts); + } + + @Post("/create") + @UseGuards(CaptchaGuard) + @UseGuards(AuthGuard("jwt")) + public async createPost(@Body() postPayload: PostCreationPayloadDto): Promise { const post = await this._postsService.authorNewPost(postPayload); return await post.toJSON(); } + + @Delete("/") + @Roles(Role.MODERATOR) + @UseGuards(AuthGuard("jwt"), RolesGuard) + public async deletePost( + @AuthedUser() user: User, + @Body() deletePostPayload: ModerationPayloadDto + ): Promise { + deletePostPayload.moderatorId = user.userId; + await this._moderationActionsService.deletePost(deletePostPayload); + } + + @Post("/vote") + @UseGuards(AuthGuard("jwt")) + public async votePost(@Body() votePostPayload: VotePostPayloadDto): Promise { + await this._postsService.votePost(votePostPayload); + return; + } + + @Post("/report") + @UseGuards(CaptchaGuard) + @UseGuards(AuthGuard("jwt")) + public async reportPost(@Body() reportPostPayload: ReportPostPayloadDto): Promise { + await this._postsReportService.reportPost(reportPostPayload); + } } diff --git a/src/posts/dtos/deletePostPayload.dto.ts b/src/posts/dtos/deletePostPayload.dto.ts index 4cf9709..7c23200 100644 --- a/src/posts/dtos/deletePostPayload.dto.ts +++ b/src/posts/dtos/deletePostPayload.dto.ts @@ -1,8 +1,10 @@ import { ApiProperty } from "@nestjs/swagger"; +import { IsUUID } from "class-validator"; export class DeletePostPayloadDto { - @ApiProperty({ type: String }) - postId: string; + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + postId: UUID; constructor(partial?: Partial) { Object.assign(this, partial); diff --git a/src/posts/dtos/hateSpeechRequestPayload.dto.ts b/src/posts/dtos/hateSpeechRequestPayload.dto.ts deleted file mode 100644 index 3336512..0000000 --- a/src/posts/dtos/hateSpeechRequestPayload.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class HateSpeechRequestPayloadDto { - token: string; - - text: string; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/posts/dtos/hateSpeechResponse.dto.ts b/src/posts/dtos/hateSpeechResponse.dto.ts deleted file mode 100644 index e31e26e..0000000 --- a/src/posts/dtos/hateSpeechResponse.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class HateSpeechResponseDto { - response: string; - - class: string; - - confidence: number; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/posts/dtos/index.ts b/src/posts/dtos/index.ts index 9675913..dd597d6 100644 --- a/src/posts/dtos/index.ts +++ b/src/posts/dtos/index.ts @@ -1,6 +1,4 @@ -export { HateSpeechResponseDto } from "./hateSpeechResponse.dto"; -export { HateSpeechRequestPayloadDto } from "./hateSpeechRequestPayload.dto"; export { DeletePostPayloadDto } from "./deletePostPayload.dto"; -export { VotePostPayloadDto, VoteType } from "./votePostPayload.dto"; -export { ReportPostPayloadDto } from "./reportPostPayload.dto"; +export { VotePostPayloadDto } from "./votePostPayload.dto"; export { PostCreationPayloadDto } from "./postCreationPayload.dto"; +export { ReportPostPayloadDto } from "./reportPostPayload.dto"; diff --git a/src/posts/dtos/reportPostPayload.dto.ts b/src/posts/dtos/reportPostPayload.dto.ts index 8ad0d2a..71db43a 100644 --- a/src/posts/dtos/reportPostPayload.dto.ts +++ b/src/posts/dtos/reportPostPayload.dto.ts @@ -1,13 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; - -export class ReportPostPayloadDto { - @ApiProperty({ type: String, format: "uuid" }) - postId: string; - - @ApiProperty({ type: String, minLength: 5, maxLength: 500 }) - reason: string; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} +import { ApiProperty } from "@nestjs/swagger"; +import { IsUUID, IsString } from "class-validator"; + +export class ReportPostPayloadDto { + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + postId: UUID; + + @ApiProperty({ type: String, minLength: 5, maxLength: 500 }) + @IsString() + reason: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/posts/dtos/votePostPayload.dto.ts b/src/posts/dtos/votePostPayload.dto.ts index bdf90d1..5e6743f 100644 --- a/src/posts/dtos/votePostPayload.dto.ts +++ b/src/posts/dtos/votePostPayload.dto.ts @@ -1,15 +1,16 @@ import { ApiProperty } from "@nestjs/swagger"; - -export enum VoteType { - UPVOTES = "UPVOTES", - DOWN_VOTES = "DOWN_VOTES", -} +import { IsEnum, IsNotEmpty, IsUUID } from "class-validator"; +import { VoteType } from "../../_domain/models/enums"; export class VotePostPayloadDto { @ApiProperty({ type: String, format: "uuid" }) - postId: string; + @IsNotEmpty() + @IsUUID() + postId: UUID; - @ApiProperty({ type: VoteType }) + @ApiProperty({ enum: VoteType }) + @IsNotEmpty() + @IsEnum(VoteType) voteType: VoteType; constructor(partial?: Partial) { diff --git a/src/posts/events/index.ts b/src/posts/events/index.ts new file mode 100644 index 0000000..629d079 --- /dev/null +++ b/src/posts/events/index.ts @@ -0,0 +1 @@ +export * from "./postGotVote.event"; diff --git a/src/posts/events/postGotVote.event.ts b/src/posts/events/postGotVote.event.ts new file mode 100644 index 0000000..8a30779 --- /dev/null +++ b/src/posts/events/postGotVote.event.ts @@ -0,0 +1,13 @@ +export class PostGotVoteEvent { + subscriberId: UUID; + + postId: UUID; + + username: string; + + avatar: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/posts/models/award.ts b/src/posts/models/award.ts index 4b9995a..4203909 100644 --- a/src/posts/models/award.ts +++ b/src/posts/models/award.ts @@ -1,18 +1,18 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; +import { IsString, IsUUID } from "class-validator"; @Labels("Award") export class Award { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - awardId: string; + @IsUUID() + awardId: UUID; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() awardName: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() awardSvg: string; constructor(partial?: Partial) { diff --git a/src/posts/models/post.spec.ts b/src/posts/models/post.spec.ts index 9c1d763..65b74e4 100644 --- a/src/posts/models/post.spec.ts +++ b/src/posts/models/post.spec.ts @@ -9,6 +9,8 @@ import { IPostsRepository } from "../repositories/post/posts.repository.interfac import { Post } from "./post"; import { _$ } from "../../_domain/injectableTokens"; +const postIdToFindInTest = "bcddeb57-939d-441b-b4ea-71e1d2055f32"; + describe("Post Model Unit Test", () => { let postsRepository: IPostsRepository; @@ -43,7 +45,7 @@ describe("Post Model Unit Test", () => { describe("given a post instance", () => { beforeAll(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("post instance must exist", async () => { @@ -67,9 +69,7 @@ describe("Post Model Unit Test", () => { describe("given post.getRestricted() called", () => { describe("given the post is not restricted", () => { beforeEach(async () => { - post = await postsRepository.findPostById( - "b73edbf4-ba84-4b11-a91c-e1d8b1366974" - ); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return null", async () => { @@ -80,9 +80,7 @@ describe("Post Model Unit Test", () => { describe("given the post is restricted", () => { beforeEach(async () => { - post = await postsRepository.findPostById( - "596632ac-dd54-4700-a783-688618d99fa9" - ); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return an object consisting the proper props", async () => { @@ -97,7 +95,7 @@ describe("Post Model Unit Test", () => { describe("given post.getAuthorUser() called", () => { beforeEach(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return an object consisting the proper props", async () => { @@ -117,7 +115,7 @@ describe("Post Model Unit Test", () => { describe("given post.getCreatedAt() called", () => { beforeEach(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return a number that represents timestamp", async () => { @@ -128,7 +126,7 @@ describe("Post Model Unit Test", () => { describe("given post.getPostType() called", () => { beforeEach(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return an object with proper properties", async () => { @@ -140,7 +138,7 @@ describe("Post Model Unit Test", () => { describe("given post.getPostTags() called", () => { beforeEach(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById(postIdToFindInTest); }); it("should return an array that consists valid objects with proper props", async () => { diff --git a/src/posts/models/post.ts b/src/posts/models/post.ts index 168f4a5..7700acc 100644 --- a/src/posts/models/post.ts +++ b/src/posts/models/post.ts @@ -1,4 +1,3 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; import { Model, @@ -7,78 +6,107 @@ import { } from "../../neo4j/neo4j.helper.types"; import { User } from "../../users/models"; import { PostType, PostTag, Award } from "./index"; -import { _ToSelfRelTypes, RestrictedProps } from "../../_domain/models/toSelf"; +import { _ToSelfRelTypes, RestrictedProps, DeletedProps } from "../../_domain/models/toSelf"; import { HasAwardProps, PostToAwardRelTypes } from "./toAward"; import { Neo4jService } from "../../neo4j/services/neo4j.service"; import { PostToPostTypeRelTypes } from "./toPostType"; import { PostToPostTagRelTypes } from "./toTags"; import { AuthoredProps, UserToPostRelTypes } from "../../users/models/toPost"; -import { DeletedProps, PostToSelfRelTypes } from "./toSelf"; -import { Exclude } from "class-transformer"; +import { Type } from "class-transformer"; import { PublicUserDto } from "../../users/dtos"; +import { PostToCommentRelTypes } from "./toComment"; +import { Comment } from "../../comments/models"; +import neo4j from "neo4j-driver"; +import { VoteType } from "../../_domain/models/enums"; +import { + IsArray, + IsBoolean, + IsEnum, + IsInstance, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; @Labels("Post") export class Post extends Model { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - postId: string; + @IsUUID() + postId: UUID; - @ApiProperty({ type: PostType }) + @IsInstance(PostType) + @IsOptional() postType: PostType; - @ApiProperty({ type: PostTag, isArray: true }) + @IsArray() + @IsOptional() postTags: PostTag[] = new Array(); - @ApiProperty({ type: Award, isArray: true }) + @IsOptional() awards: RichRelatedEntities; - @ApiProperty({ type: Number }) + @IsNumber() createdAt: number; - @ApiProperty({ type: Number }) @NodeProperty() updatedAt: number; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() postTitle: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() postContent: string; - @ApiProperty({ type: User }) + @IsInstance(User) authorUser: User | any; - @ApiProperty({ type: Boolean }) @NodeProperty() + @IsBoolean() pending: boolean; - @ApiProperty({ type: RestrictedProps }) + @IsInstance(RestrictedProps) + @IsOptional() restrictedProps: Nullable = null; - @ApiProperty({ type: Number }) + @IsNumber() totalVotes: number; - @ApiProperty({ type: Boolean }) + @IsNumber() + @IsOptional() + totalComments: number | undefined; + + @IsArray({ each: true }) + @Type(() => Comment) + comments: Comment[]; + @NodeProperty() - @Exclude() + @IsOptional() + @IsInstance(DeletedProps) deletedProps: Nullable = null; + @IsOptional() + @IsEnum(VoteType) + userVote: Nullable | undefined = undefined; + constructor(partial?: Partial, neo4jService?: Neo4jService) { super(neo4jService); Object.assign(this, partial); } - public async toJSON() { + public async toJSON(props: ToJSONProps = {}) { if (this.neo4jService) { await Promise.all([ - this.getPostType(), - this.getPostTags(), - this.getAwards(), - this.getRestricted(), - this.getCreatedAt(), - this.getTotalVotes(), - this.getAuthorUser(), + ...(!this.postType ? [this.getPostType()] : []), + ...(this.postTags.length === 0 ? [this.getPostTags()] : []), + ...(!this.awards ? [this.getAwards()] : []), + ...(!this.restrictedProps ? [this.getRestricted()] : []), + ...(!this.deletedProps ? [this.getDeletedProps()] : []), + ...(!this.createdAt ? [this.getCreatedAt()] : []), + ...(!this.totalVotes ? [this.getTotalVotes()] : []), + ...(!this.authorUser ? [this.getAuthorUser()] : []), + ...(props.authenticatedUserId ? [this.getUserVote(props.authenticatedUserId)] : []), ]); } @@ -87,6 +115,51 @@ export class Post extends Model { return { ...this }; } + public async getComments(limit = 0): Promise { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (p:Post {postId: $postId})-[:${PostToCommentRelTypes.HAS_COMMENT}]->(c:Comment) + RETURN c + ${limit != 0 ? `LIMIT $limit` : ""} + `, + { + postId: this.postId, + ...(limit != 0 ? { limit: neo4j.int(limit) } : {}), + } + ); + const records = queryResult.records; + if (records.length === 0) { + this.comments = []; + return this.comments; + } + const comments = records.map(r => new Comment(r.get("c").properties, this.neo4jService)); + this.comments = comments; + return comments; + } + + public async getUserVote(userId): Promise> { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToPostRelTypes.UPVOTES}|${UserToPostRelTypes.DOWN_VOTES}]->(p:Post { postId: $postId }) + RETURN r + `, + { + userId, + postId: this.postId, + } + ); + + if (queryResult.records.length > 0) { + // user has already voted on this post + const relType = queryResult.records[0].get("r").type as VoteType; + this.userVote = relType; + return relType; + } + + this.userVote = null; + return null; + } + public async getTotalVotes(): Promise { const queryResult = await this.neo4jService.tryReadAsync( ` @@ -145,7 +218,7 @@ export class Post extends Model { public async getDeletedProps(): Promise { const queryResult = await this.neo4jService.tryReadAsync( ` - MATCH (p:Post {postId: $postId})-[r:${PostToSelfRelTypes.DELETED}]->(p) + MATCH (p:Post {postId: $postId})-[r:${_ToSelfRelTypes.DELETED}]->(p) RETURN r `, { @@ -158,21 +231,6 @@ export class Post extends Model { return result; } - public async setDeletedProps(deletedProps: DeletedProps): Promise { - await this.neo4jService.tryWriteAsync( - ` - MATCH (p:Post {postId: $postId}) - MERGE (p)-[r:${PostToSelfRelTypes.DELETED}]->(p) - SET r = $deletedProps - `, - { - postId: this.postId, - deletedProps, - } - ); - this.deletedProps = deletedProps; - } - public async getRestricted(): Promise> { const queryResult = await this.neo4jService.tryReadAsync( ` @@ -246,3 +304,7 @@ export class Post extends Model { return result; } } + +interface ToJSONProps { + authenticatedUserId?: string; +} diff --git a/src/posts/models/postTag.ts b/src/posts/models/postTag.ts index 8e01cfc..65d528f 100644 --- a/src/posts/models/postTag.ts +++ b/src/posts/models/postTag.ts @@ -1,14 +1,14 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; +import { IsString } from "class-validator"; @Labels("PostTag") export class PostTag { - @ApiProperty({ type: String }) @NodeProperty() + @IsString() tagName: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() tagColor: string; constructor(partial?: Partial) { diff --git a/src/posts/models/postType.ts b/src/posts/models/postType.ts index 4c543ba..7c31918 100644 --- a/src/posts/models/postType.ts +++ b/src/posts/models/postType.ts @@ -1,10 +1,10 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; +import { IsString } from "class-validator"; @Labels("PostType") export class PostType { - @ApiProperty({ type: String }) @NodeProperty() + @IsString() postTypeName: string; constructor(partial?: Partial) { diff --git a/src/posts/models/toAward/hasAward.props.ts b/src/posts/models/toAward/hasAward.props.ts index 11e5c9d..206444f 100644 --- a/src/posts/models/toAward/hasAward.props.ts +++ b/src/posts/models/toAward/hasAward.props.ts @@ -1,9 +1,9 @@ import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; -import { ApiProperty } from "@nestjs/swagger"; +import { IsUUID } from "class-validator"; export class HasAwardProps implements RelationshipProps { - @ApiProperty({ type: String, format: "uuid" }) - awardedBy: string; + @IsUUID() + awardedBy: UUID; constructor(partial?: Partial) { Object.assign(this, partial); diff --git a/src/posts/models/toComment/hasComment.props.ts b/src/posts/models/toComment/hasComment.props.ts index 0040681..426526a 100644 --- a/src/posts/models/toComment/hasComment.props.ts +++ b/src/posts/models/toComment/hasComment.props.ts @@ -1,8 +1,8 @@ import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; -import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean } from "class-validator"; export class HasCommentProps implements RelationshipProps { - @ApiProperty({ type: Boolean }) + @IsBoolean() pinned: boolean; constructor(partial?: Partial) { diff --git a/src/posts/models/toSelf/deleted.props.ts b/src/posts/models/toSelf/deleted.props.ts deleted file mode 100644 index cce4bab..0000000 --- a/src/posts/models/toSelf/deleted.props.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; -import { ApiProperty } from "@nestjs/swagger"; - -export class DeletedProps implements RelationshipProps { - @ApiProperty({ type: Number }) - deletedAt: number; - - @ApiProperty({ type: String }) - deletedByUserId: string; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/posts/models/toSelf/index.ts b/src/posts/models/toSelf/index.ts deleted file mode 100644 index 088a945..0000000 --- a/src/posts/models/toSelf/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { DeletedProps } from "./deleted.props"; - -export enum PostToSelfRelTypes { - DELETED = "DELETED", -} diff --git a/src/posts/posts.module.ts b/src/posts/posts.module.ts index 0a0714f..5bd1532 100644 --- a/src/posts/posts.module.ts +++ b/src/posts/posts.module.ts @@ -1,8 +1,6 @@ import { HttpModule } from "@nestjs/axios"; import { forwardRef, Module } from "@nestjs/common"; import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module"; -import { GenderRepository } from "../users/repositories/gender/gender.repository"; -import { SexualityRepository } from "../users/repositories/sexuality/sexuality.repository"; import { _$ } from "../_domain/injectableTokens"; import { PostsController } from "./controllers/posts.controller"; import { PostTagsController } from "./controllers/postTags.controller"; @@ -13,9 +11,18 @@ import { PostTypesRepository } from "./repositories/postType/postTypes.repositor import { PostAwardRepository } from "./repositories/postAward/postAward.repository"; import { PostTypesController } from "./controllers/postTypes.controller"; import { ModerationModule } from "../moderation/moderation.module"; +import { PostsReportService } from "./services/postReport/postsReport.service"; +import { GoogleCloudRecaptchaEnterpriseModule } from "../google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module"; +import { PusherModule } from "../pusher/pusher.module"; @Module({ - imports: [forwardRef(() => DatabaseAccessLayerModule), HttpModule, ModerationModule], + imports: [ + forwardRef(() => DatabaseAccessLayerModule), + HttpModule, + ModerationModule, + GoogleCloudRecaptchaEnterpriseModule, + PusherModule, + ], providers: [ { provide: _$.IPostsRepository, @@ -33,18 +40,14 @@ import { ModerationModule } from "../moderation/moderation.module"; provide: _$.IPostTypesRepository, useClass: PostTypesRepository, }, - { - provide: _$.IGenderRepository, - useClass: GenderRepository, - }, - { - provide: _$.ISexualityRepository, - useClass: SexualityRepository, - }, { provide: _$.IPostAwardRepository, useClass: PostAwardRepository, }, + { + provide: _$.IPostsReportService, + useClass: PostsReportService, + }, ], exports: [ { @@ -63,18 +66,14 @@ import { ModerationModule } from "../moderation/moderation.module"; provide: _$.IPostTypesRepository, useClass: PostTypesRepository, }, - { - provide: _$.IGenderRepository, - useClass: GenderRepository, - }, - { - provide: _$.ISexualityRepository, - useClass: SexualityRepository, - }, { provide: _$.IPostAwardRepository, useClass: PostAwardRepository, }, + { + provide: _$.IPostsReportService, + useClass: PostsReportService, + }, ], controllers: [PostsController, PostTagsController, PostTypesController], }) diff --git a/src/posts/repositories/post/posts.repository.interface.ts b/src/posts/repositories/post/posts.repository.interface.ts index d5e0f64..e1a6381 100644 --- a/src/posts/repositories/post/posts.repository.interface.ts +++ b/src/posts/repositories/post/posts.repository.interface.ts @@ -1,20 +1,32 @@ import { Post } from "../../models"; -import { RestrictedProps } from "../../../_domain/models/toSelf"; +import { DeletedProps, RestrictedProps } from "../../../_domain/models/toSelf"; export interface IPostsRepository { findAll(): Promise; findPostByPostType(postTypeName: string): Promise; - findPostById(postId: string): Promise; + findPostById(postId: UUID): Promise; + + findPostsByUserId(userId: UUID): Promise; + + getPostHistoryByUserId(userId: UUID): Promise; addPost(post: Post, anonymous: boolean): Promise; updatePost(post: Post): Promise; - deletePost(postId: string): Promise; + deletePost(postId: UUID): Promise; + + markAsDeleted(postId: UUID, deletedProps: DeletedProps): Promise; + + removeDeletedMark(postId: UUID): Promise; + + restrictPost(postId: UUID, restrictedProps: RestrictedProps): Promise; + + unrestrictPost(postId: UUID): Promise; - restrictPost(postId: string, restrictedProps: RestrictedProps): Promise; + getPendingPosts(): Promise; - unrestrictPost(postId: string): Promise; + getDeletedPosts(): Promise; } diff --git a/src/posts/repositories/post/posts.repository.spec.ts b/src/posts/repositories/post/posts.repository.spec.ts index deeac2e..4b93abb 100644 --- a/src/posts/repositories/post/posts.repository.spec.ts +++ b/src/posts/repositories/post/posts.repository.spec.ts @@ -88,7 +88,7 @@ describe("PostsRepository", () => { let post: Post; beforeAll(async () => { - post = await postsRepository.findPostById("b73edbf4-ba84-4b11-a91c-e1d8b1366974"); + post = await postsRepository.findPostById("bcddeb57-939d-441b-b4ea-71e1d2055f32"); }); it("should return a post", async () => { @@ -107,16 +107,17 @@ describe("PostsRepository", () => { describe(".addPost() and .deletePost()", () => { const samplePostId = "64f5ef93-31ac-4c61-b98a-79268d282fc7"; - const sampleAuthorId = "3109f9e2-a262-4aef-b648-90d86d6fbf6c"; - const sampleAwardedByUserId = "5c0f145b-ffad-4881-8ee6-7647c3c1b695"; + const sampleAuthorId = "a59437f4-ea62-4a15-a4e6-621b04af74d6"; + const sampleAwardedByUserId = "0daef999-7291-4f0c-a41a-078a6f28aa5e"; beforeAll(async () => { + const postTags = await neo4jSeedService.getPostTags(); const postToAdd = new Post({ postId: samplePostId, postTitle: "Test Post Title", postContent: "This is a test post. will be removed", - updatedAt: 1665770000, - postType: (await neo4jSeedService.getPostTypes())[0], - postTags: (await neo4jSeedService.getPostTags()).slice(0, 2), + updatedAt: new Date("2020-05-20").getTime(), + postType: (await neo4jSeedService.getPostTypes()).queery, + postTags: [postTags.Serious, postTags.Casual], restrictedProps: null, authorUser: new User({ userId: sampleAuthorId }), pending: false, @@ -151,14 +152,14 @@ describe("PostsRepository", () => { describe(".restrictPost() and .unrestrictPost()", () => { let post: Post; - const postId = "b73edbf4-ba84-4b11-a91c-e1d8b1366974"; + const postId = "bcddeb57-939d-441b-b4ea-71e1d2055f32"; beforeAll(async () => { await postsRepository.restrictPost( postId, new RestrictedProps({ restrictedAt: new Date().getTime(), - moderatorId: "5c0f145b-ffad-4881-8ee6-7647c3c1b695", + moderatorId: "0daef999-7291-4f0c-a41a-078a6f28aa5e", reason: "Test", }) ); diff --git a/src/posts/repositories/post/posts.repository.ts b/src/posts/repositories/post/posts.repository.ts index 8f34590..1008fe2 100644 --- a/src/posts/repositories/post/posts.repository.ts +++ b/src/posts/repositories/post/posts.repository.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from "@nestjs/common"; import { IPostsRepository } from "./posts.repository.interface"; import { Neo4jService } from "../../../neo4j/services/neo4j.service"; -import { _ToSelfRelTypes, RestrictedProps } from "../../../_domain/models/toSelf"; +import { _ToSelfRelTypes, DeletedProps, RestrictedProps } from "../../../_domain/models/toSelf"; import { PostToPostTypeRelTypes } from "../../models/toPostType"; import { PostToPostTagRelTypes } from "../../models/toTags"; import { HasAwardProps, PostToAwardRelTypes } from "../../models/toAward"; @@ -13,7 +13,7 @@ export class PostsRepository implements IPostsRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allPosts = await this._neo4jService.read(`MATCH (p:Post) RETURN p`, {}); + const allPosts = await this._neo4jService.tryReadAsync(`MATCH (p:Post) RETURN p`, {}); const records = allPosts.records; if (records.length === 0) return []; return records.map(record => new Post(record.get("p").properties, this._neo4jService)); @@ -31,8 +31,8 @@ export class PostsRepository implements IPostsRepository { return records.map(record => new Post(record.get("p").properties, this._neo4jService)); } - public async findPostById(postId: string): Promise { - const post = await this._neo4jService.read( + public async findPostById(postId: UUID): Promise { + const post = await this._neo4jService.tryReadAsync( `MATCH (p:Post) WHERE p.postId = $postId RETURN p`, { postId: postId } ); @@ -40,6 +40,35 @@ export class PostsRepository implements IPostsRepository { return new Post(post.records[0].get("p").properties, this._neo4jService); } + public async findPostsByUserId(userId: string): Promise { + const allPosts = await this._neo4jService.tryReadAsync( + ` + MATCH (u:User { userId: $userId})-[r:${UserToPostRelTypes.AUTHORED}]->(p:Post) + RETURN p + `, + { + userId: userId, + } + ); + const records = allPosts.records; + if (records.length === 0) return []; + return records.map(record => new Post(record.get("p").properties, this._neo4jService)); + } + + public async getPostHistoryByUserId(userId: UUID): Promise { + const userPosts = await this._neo4jService.tryReadAsync( + `MATCH (u:User { userId: $userId })-[:${UserToPostRelTypes.AUTHORED}]->(p:Post) + RETURN p`, + { + userId, + } + ); + if (userPosts.records.length === 0) return []; + return userPosts.records.map( + record => new Post(record.get("p").properties, this._neo4jService) + ); + } + public async addPost(post: Post, anonymous: boolean): Promise { if (post.postId === undefined) { post.postId = this._neo4jService.generateId(); @@ -147,7 +176,7 @@ export class PostsRepository implements IPostsRepository { `, { postId: post.postId, - updatedAt: post.updatedAt, + updatedAt: Date.now(), postTitle: post.postTitle, postContent: post.postContent, pending: post.pending, @@ -155,15 +184,41 @@ export class PostsRepository implements IPostsRepository { ); } - public async deletePost(postId: string): Promise { + public async deletePost(postId: UUID): Promise { this._neo4jService.write(`MATCH (p:Post) WHERE p.postId = $postId DETACH DELETE p`, { postId: postId, }); } - public async restrictPost(postId: string, restrictedProps: RestrictedProps): Promise { + public async markAsDeleted(postId: UUID, deletedProps: DeletedProps): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (p:Post {postId: $postId}) + MERGE (p)-[r:${_ToSelfRelTypes.DELETED}]->(p) + SET r = $deletedProps + `, + { + postId, + deletedProps, + } + ); + } + + public async removeDeletedMark(postId: UUID): Promise { await this._neo4jService.tryWriteAsync( - `MATCH (p:Post) WHERE p.postId = $postId + ` + MATCH (p:Post {postId: $postId})-[r:${_ToSelfRelTypes.DELETED}]->(p) + DELETE r + `, + { + postId, + } + ); + } + + public async restrictPost(postId: UUID, restrictedProps: RestrictedProps): Promise { + await this._neo4jService.tryWriteAsync( + `MATCH (p:Post { postId: $postId }) CREATE (p)-[r:${_ToSelfRelTypes.RESTRICTED} { restrictedAt: $restrictedAt, moderatorId: $moderatorId, @@ -178,7 +233,7 @@ export class PostsRepository implements IPostsRepository { ); } - public async unrestrictPost(postId: string): Promise { + public async unrestrictPost(postId: UUID): Promise { await this._neo4jService.tryWriteAsync( `MATCH (p:Post)-[r:${_ToSelfRelTypes.RESTRICTED}]->(p) WHERE p.postId = $postId DELETE r`, { @@ -186,4 +241,32 @@ export class PostsRepository implements IPostsRepository { } ); } + + public async getPendingPosts(): Promise { + const queryResult = await this._neo4jService.tryReadAsync( + ` + MATCH (p:Post { pending: $pending }) + RETURN p + `, + { + pending: true, + } + ); + const records = queryResult.records; + if (records.length === 0) return []; + return records.map(record => new Post(record.get("p").properties, this._neo4jService)); + } + + public async getDeletedPosts(): Promise { + const queryResult = await this._neo4jService.tryReadAsync( + ` + MATCH (p:Post)-[r:${_ToSelfRelTypes.DELETED}]->(p) + RETURN p + `, + {} + ); + const records = queryResult.records; + if (records.length === 0) return []; + return records.map(record => new Post(record.get("p").properties, this._neo4jService)); + } } diff --git a/src/posts/repositories/postAward/postAward.repository.ts b/src/posts/repositories/postAward/postAward.repository.ts index dbf512e..2753308 100644 --- a/src/posts/repositories/postAward/postAward.repository.ts +++ b/src/posts/repositories/postAward/postAward.repository.ts @@ -9,14 +9,14 @@ export class PostAwardRepository implements IPostAwardRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allAwards = await this._neo4jService.read(`MATCH (a:Award) RETURN a`, {}); + const allAwards = await this._neo4jService.tryReadAsync(`MATCH (a:Award) RETURN a`, {}); const records = allAwards.records; if (records.length === 0) return []; return records.map(record => new Award(record.get("a").properties)); } public async findAwardById(awardId: string): Promise { - const award = await this._neo4jService.read( + const award = await this._neo4jService.tryReadAsync( `MATCH (a:Award) WHERE a.awardId = $awardId RETURN a`, { awardId: awardId } ); diff --git a/src/posts/repositories/postTag/postTags.repository.ts b/src/posts/repositories/postTag/postTags.repository.ts index 616d747..98cb0d9 100644 --- a/src/posts/repositories/postTag/postTags.repository.ts +++ b/src/posts/repositories/postTag/postTags.repository.ts @@ -8,7 +8,7 @@ export class PostTagsRepository implements IPostTagsRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allPostTags = await this._neo4jService.read(`MATCH (t:PostTag) RETURN t`, {}); + const allPostTags = await this._neo4jService.tryReadAsync(`MATCH (t:PostTag) RETURN t`, {}); const records = allPostTags.records; if (records.length === 0) return []; return records.map(record => new PostTag(record.get("t").properties)); diff --git a/src/posts/repositories/postType/postTypes.repository.ts b/src/posts/repositories/postType/postTypes.repository.ts index c70f05d..ea1fdc4 100644 --- a/src/posts/repositories/postType/postTypes.repository.ts +++ b/src/posts/repositories/postType/postTypes.repository.ts @@ -8,7 +8,10 @@ export class PostTypesRepository implements IPostTypesRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allPostTypes = await this._neo4jService.read(`MATCH (t:PostType) RETURN t`, {}); + const allPostTypes = await this._neo4jService.tryReadAsync( + `MATCH (t:PostType) RETURN t`, + {} + ); const records = allPostTypes.records; if (records.length === 0) return []; return records.map(record => new PostType(record.get("t").properties)); diff --git a/src/posts/services/postReport/postsReport.service.ts b/src/posts/services/postReport/postsReport.service.ts index d9a5ce3..234ace2 100644 --- a/src/posts/services/postReport/postsReport.service.ts +++ b/src/posts/services/postReport/postsReport.service.ts @@ -24,9 +24,8 @@ export class PostsReportService implements IPostsReportService { public async reportPost(reportPostPayload: ReportPostPayloadDto): Promise { const user = this.getUserFromRequest(); - const post = await this._dbContext.Posts.findPostById(reportPostPayload.postId); - if (post === undefined) throw new Error("Post not found"); + if (!post) throw new HttpException("Post not found", 404); if (post.pending || post.restrictedProps !== null) { throw new HttpException( @@ -35,10 +34,10 @@ export class PostsReportService implements IPostsReportService { ); } - const reports = await this.getReportsForPost(post.postId); + const report = await this.checkIfUserReportedPost(post.postId, user.userId); - if (reports.some(r => r.reportedBy.userId === user.userId)) { - throw new HttpException("Post already reported", 400); + if (report === true) { + throw new HttpException("You have already reported this post", 400); } await post.getAuthorUser(); @@ -49,36 +48,52 @@ export class PostsReportService implements IPostsReportService { await this._dbContext.neo4jService.tryWriteAsync( ` MATCH (p:Post { postId: $postId }), (u:User { userId: $userId }) - MERGE (u)-[r:${UserToPostRelTypes.REPORTED}]->(p) + MERGE (u)-[r:${UserToPostRelTypes.REPORTED}{reportedAt: $reportedAt, reason: $reason}]->(p) `, { + reportedAt: Date.now(), + reason: reportPostPayload.reason, postId: post.postId, userId: user.userId, } ); } - public async getReportsForPost(postId: string): Promise { + public async getReportsForPost(postId: UUID): Promise { const queryResult = await this._dbContext.neo4jService.tryReadAsync( ` - MATHC (p:Post { postId: $postId })<-[r:${UserToPostRelTypes.REPORTED}]-(u:User)`, + MATCH (p:Post { postId: $postId })<-[r:${UserToPostRelTypes.REPORTED}]-(u:User) + RETURN r, u`, { postId: postId, } ); return queryResult.records.map(record => { const reportedProps = new ReportedProps(record.get("r").properties); - reportedProps.reportedBy = new User( - record.get("u").properties, - this._dbContext.neo4jService - ); return reportedProps; }); } + private async checkIfUserReportedPost(postId: UUID, userId: UUID): Promise { + const queryResult = await this._dbContext.neo4jService.tryReadAsync( + ` + MATCH (p:Post { postId: $postId })<-[r:${UserToPostRelTypes.REPORTED}]-(u:User { userId: $userId }) + RETURN r + `, + { + postId: postId, + userId: userId, + } + ); + if (queryResult.records.length > 0) { + return true; + } + return false; + } + private getUserFromRequest(): User { const user = this._request.user as User; - if (user === undefined) throw new Error("User not found"); + if (user === undefined) throw new HttpException("User not found", 404); return user; } } diff --git a/src/posts/services/posts/posts.service.interface.ts b/src/posts/services/posts/posts.service.interface.ts index 868ee28..97c67dd 100644 --- a/src/posts/services/posts/posts.service.interface.ts +++ b/src/posts/services/posts/posts.service.interface.ts @@ -1,4 +1,5 @@ -import { PostCreationPayloadDto } from "../../dtos"; +import { Comment } from "../../../comments/models"; +import { PostCreationPayloadDto, VotePostPayloadDto } from "../../dtos"; import { Post } from "../../models"; export type postSortCallback = (postA: Post, postB: Post) => number; @@ -6,13 +7,26 @@ export type postSortCallback = (postA: Post, postB: Post) => number; export interface IPostsService { authorNewPost(postPayload: PostCreationPayloadDto): Promise; - getQueeryOfTheDay(): Promise; + getQueeriesOfTheDay(): Promise; - findAllQueeries(sorted: null | postSortCallback): Promise; + getStoriesOfTheDay(): Promise; - findAllStories(sorted: null | postSortCallback): Promise; + findAllQueeries(): Promise; - findPostById(postId: string): Promise; + findAllStories(): Promise; - markAsDeleted(postId: string): Promise; + findPostById(postId: UUID): Promise; + + findPostsByUserId(userId: UUID): Promise; + + findNestedCommentsByPostId( + postId: UUID, + topLevelLimit: number, + nestedLimit: number, + nestedLevel: number + ): Promise; + + getNestedComments(comments: Comment[], nestedLevel: number, nestedLimit: number): Promise; + + votePost(votePostPayload: VotePostPayloadDto): Promise; } diff --git a/src/posts/services/posts/posts.service.ts b/src/posts/services/posts/posts.service.ts index 07de57a..b3da5ae 100644 --- a/src/posts/services/posts/posts.service.ts +++ b/src/posts/services/posts/posts.service.ts @@ -1,27 +1,36 @@ -import { HttpException, Inject, Injectable, Scope } from "@nestjs/common"; -import { PostCreationPayloadDto, VotePostPayloadDto, VoteType } from "../../dtos"; -import { User } from "../../../users/models"; -import { Post, PostTag } from "../../models"; -import { IPostsService, postSortCallback } from "./posts.service.interface"; -import { _$ } from "../../../_domain/injectableTokens"; -import { DatabaseContext } from "../../../database-access-layer/databaseContext"; -import { DeletedProps } from "../../models/toSelf"; +import { HttpException, Inject, Injectable, Logger, Scope } from "@nestjs/common"; import { REQUEST } from "@nestjs/core"; import { Request } from "express"; -import { UserToPostRelTypes } from "../../../users/models/toPost"; +import { Comment } from "../../../comments/models"; +import { DatabaseContext } from "../../../database-access-layer/databaseContext"; import { IAutoModerationService } from "../../../moderation/services/autoModeration/autoModeration.service.interface"; +import { User } from "../../../users/models"; +import { UserToPostRelTypes, VoteProps } from "../../../users/models/toPost"; +import { _$ } from "../../../_domain/injectableTokens"; +import { VoteType } from "../../../_domain/models/enums"; +import { PostCreationPayloadDto, VotePostPayloadDto } from "../../dtos"; +import { Post, PostTag } from "../../models"; +import { IPostsService, postSortCallback } from "./posts.service.interface"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PostGotVoteEvent } from "../../events"; +import { EventTypes } from "../../../_domain/eventTypes"; @Injectable({ scope: Scope.REQUEST }) export class PostsService implements IPostsService { + private readonly _logger = new Logger(PostsService.name); + + private readonly _eventEmitter: EventEmitter2; private readonly _request: Request; private readonly _dbContext: DatabaseContext; private readonly _autoModerationService: IAutoModerationService; constructor( + eventEmitter: EventEmitter2, @Inject(REQUEST) request: Request, @Inject(_$.IDatabaseContext) databaseContext: DatabaseContext, @Inject(_$.IAutoModerationService) autoModerationService: IAutoModerationService ) { + this._eventEmitter = eventEmitter; this._request = request; this._dbContext = databaseContext; this._autoModerationService = autoModerationService; @@ -46,8 +55,11 @@ export class PostsService implements IPostsService { } // auto-moderation - const wasOffending = await this._autoModerationService.checkForHateSpeech( - postPayload.postTitle + postPayload.postContent + const titleWasOffending = await this._autoModerationService.checkForHateSpeech( + postPayload.postTitle + ); + const contentWasOffending = await this._autoModerationService.checkForHateSpeech( + postPayload.postContent ); // if moderation passed, create post and return it. @@ -58,100 +70,229 @@ export class PostsService implements IPostsService { postTitle: postPayload.postTitle, postContent: postPayload.postContent, authorUser: user, - pending: wasOffending, + pending: titleWasOffending || contentWasOffending, }), postPayload.anonymous ); } - public async getQueeryOfTheDay(): Promise { - const allPosts = await this._dbContext.Posts.findAll(); - if (allPosts.length === 0) + public async getQueeriesOfTheDay(): Promise { + let user: User = null; + try { + user = this.getUserFromRequest(); + } catch (e) { + // do nothing + } + + const allQueeries = await this._dbContext.Posts.findPostByPostType("queery"); + if (allQueeries.length === 0) throw new HttpException( "No posts found in the database. Please checkout this application's usage tutorials.", 404 ); - const queeryPosts: Post[] = []; - for (const i in allPosts) { - if (!allPosts[i].pending) continue; + const queeries: Post[] = []; + for (const i in allQueeries) { + if (allQueeries[i].pending) continue; - await allPosts[i].getDeletedProps(); - if (allPosts[i].deletedProps !== null) continue; + await allQueeries[i].getDeletedProps(); + if (allQueeries[i].deletedProps !== null) continue; - await allPosts[i].getRestricted(); - if (allPosts[i].restrictedProps !== null) continue; + await allQueeries[i].getRestricted(); + if (allQueeries[i].restrictedProps !== null) continue; - await allPosts[i].getPostType(); - if (allPosts[i].postType.postTypeName === "Queery") { - queeryPosts.push(allPosts[i]); - } + allQueeries[i].totalComments = await this.getTotalComments(allQueeries[i]); + + allQueeries[i] = await allQueeries[i].toJSON({ + authenticatedUserId: user?.userId ?? undefined, + }); + + queeries.push(allQueeries[i]); } - if (queeryPosts.length === 0) throw new HttpException("No Queery posts found", 404); + queeries.sort( + (postA, postB) => + postA.totalComments + postA.totalVotes - (postB.totalComments + postB.totalVotes) + ); - const queeryOfTheDayIndex = Math.floor(Math.random() * queeryPosts.length); - return queeryPosts[queeryOfTheDayIndex]; + return queeries.slice(0, 5); } - public async findAllQueeries(sorted: null | postSortCallback): Promise { - const queeries = await this._dbContext.Posts.findPostByPostType("queery"); - if (sorted !== null) { - queeries.sort(sorted); + public async getStoriesOfTheDay(): Promise { + let user: User = null; + try { + user = this.getUserFromRequest(); + } catch (e) { + // do nothing } - return queeries; + + const allStories = await this._dbContext.Posts.findPostByPostType("story"); + if (allStories.length === 0) + throw new HttpException( + "No posts found in the database. Please checkout this application's usage tutorials.", + 404 + ); + + const stories: Post[] = []; + for (const i in allStories) { + if (allStories[i].pending) continue; + + await allStories[i].getDeletedProps(); + if (allStories[i].deletedProps !== null) continue; + + await allStories[i].getRestricted(); + if (allStories[i].restrictedProps !== null) continue; + + allStories[i].totalComments = await this.getTotalComments(allStories[i]); + + allStories[i] = await allStories[i].toJSON({ + authenticatedUserId: user?.userId ?? undefined, + }); + + stories.push(allStories[i]); + } + + stories.sort( + (postA, postB) => + postA.totalComments + postA.totalVotes - (postB.totalComments + postB.totalVotes) + ); + + return stories.slice(0, 5); } - public async findAllStories(sorted: null | postSortCallback): Promise { - const stories = await this._dbContext.Posts.findPostByPostType("story"); + public async findAllQueeries(): Promise { + let queeries = await this._dbContext.Posts.findPostByPostType("queery"); + queeries = await this.filterPublicPosts(queeries); + return this.decoratePosts(queeries, (postA, postB) => postB.createdAt - postA.createdAt); + } + + public async findAllStories(): Promise { + let stories = await this._dbContext.Posts.findPostByPostType("story"); + stories = await this.filterPublicPosts(stories); + return this.decoratePosts(stories, (postA, postB) => postB.createdAt - postA.createdAt); + } + + private async filterPublicPosts(posts: Post[]): Promise { + const filteredPosts = []; + for (const post of posts) { + if (post.pending) continue; + + await post.getRestricted(); + if (post.restrictedProps) continue; + + await post.getDeletedProps(); + if (post.deletedProps) continue; + + filteredPosts.push(post); + } + return filteredPosts; + } + + private async decoratePosts(posts: Post[], sorted: null | postSortCallback): Promise { + posts = await Promise.all( + posts.map(async post => { + post.totalComments = await this.getTotalComments(post); + return post; + }) + ); if (sorted !== null) { - stories.sort(sorted); + posts.sort(sorted); } - return stories; + return posts; } - public async findPostById(postId: string): Promise { - const foundPost = await this._dbContext.Posts.findPostById(postId); - if (foundPost === null) throw new HttpException("Post not found", 404); + private async getTotalComments(post: Post, comments = null, result = 0): Promise { + if (comments === null) { + comments = await this.findNestedCommentsByPostId(post.postId, 0, 0, Infinity); + } + + if (comments.length === 0) return result; + result += comments.length; + for (const comment of comments) { + result = await this.getTotalComments(post, comment.childComments ?? [], result); + } + return result; + } + + public async findPostById(postId: UUID): Promise { + let foundPost = await this._dbContext.Posts.findPostById(postId); + if (!foundPost) throw new HttpException("Post not found", 404); if (foundPost.pending) - throw new HttpException("Post cannot be shown publicly due to striking policies", 403); + throw new HttpException( + "Post cannot be shown publicly at the moment. please try again later.", + 403 + ); - await foundPost.getDeletedProps(); - if (foundPost.deletedProps !== null) throw new HttpException("Post was deleted", 404); + foundPost = (await this.decoratePosts([foundPost], null))[0]; - await foundPost.getRestricted(); - if (foundPost.restrictedProps !== null) throw new HttpException("Post is restricted", 404); + return foundPost; + } - return await foundPost.toJSON(); + public async findPostsByUserId(userId: UUID): Promise { + let foundPosts = await this._dbContext.Posts.findPostsByUserId(userId); + foundPosts = await this.filterPublicPosts(foundPosts); + return this.decoratePosts(foundPosts, (postA, postB) => postB.createdAt - postA.createdAt); } - public async markAsDeleted(postId: string): Promise { - const post = await this._dbContext.Posts.findPostById(postId); - if (post === undefined) throw new Error("Post not found"); + public async findNestedCommentsByPostId( + postId: UUID, + topLevelLimit: number, + nestedLimit: number, + nestedLevel: number + ): Promise { + const foundPost = await this._dbContext.Posts.findPostById(postId); + if (!foundPost) throw new HttpException("Post not found", 404); - await post.getDeletedProps(); - if (post.deletedProps !== null) throw new Error("Post already deleted"); + // level 0 means no nesting + const comments = await foundPost.getComments(topLevelLimit); + if (nestedLevel === 0) return comments; - await post.getAuthorUser(); + await this.getNestedComments(comments, nestedLevel, nestedLimit); - await post.setDeletedProps( - new DeletedProps({ - deletedAt: new Date().getTime(), - deletedByUserId: post.authorUser.userId, - }) - ); + return comments; + } + + /** + * Recursively gets the total number of every comment's child comments that are **available**. + * @param comment + * @param result + * @private + */ + private async getTotalCommentsByComment(comment: Comment, result = 0): Promise { + if (!comment.childComments || comment.childComments.length === 0) return result; + result += comment.childComments.length; + for (const childComment of comment.childComments) { + result = await this.getTotalCommentsByComment(childComment, result); + childComment.totalComments = await this.getTotalCommentsByComment(childComment, 0); + } + return result; + } + + public async getNestedComments( + comments: Comment[], + nestedLevel: number, + nestedLimit: number + ): Promise { + if (nestedLevel === 0) return; + for (const i in comments) { + const comment: Comment = comments[i]; + await comment.getChildrenComments(nestedLimit); + // comment.totalComments = await this.getTotalCommentsByComment(comment); + await this.getNestedComments(comment.childComments, nestedLevel - 1, nestedLimit); + } } public async votePost(votePostPayload: VotePostPayloadDto): Promise { const user = this.getUserFromRequest(); const post = await this._dbContext.Posts.findPostById(votePostPayload.postId); - if (post === undefined) throw new HttpException("Post not found", 404); + if (!post) throw new HttpException("Post not found", 404); const queryResult = await this._dbContext.neo4jService.tryReadAsync( ` - MATCH (u:User { userId: $userId })-[r:${UserToPostRelTypes.UPVOTES}|${UserToPostRelTypes.DOWN_VOTES}]->(p:Post { postId: $postId }) + MATCH (u:User { userId: $userId })-[r:${UserToPostRelTypes.UPVOTES}|${UserToPostRelTypes.DOWN_VOTES}]->(p:Post { postId: $postId }) + RETURN r `, { userId: user.userId, @@ -160,46 +301,73 @@ export class PostsService implements IPostsService { ); if (queryResult.records.length > 0) { + // user has already voted on this post const relType = queryResult.records[0].get("r").type; - if ( - relType === UserToPostRelTypes.UPVOTES && - votePostPayload.voteType === VoteType.UPVOTES - ) { - throw new HttpException("User already upvoted this post", 400); - } else if ( - relType === UserToPostRelTypes.DOWN_VOTES && - votePostPayload.voteType === VoteType.DOWN_VOTES - ) { - throw new HttpException("User already downvoted this post", 400); - } else { - await this._dbContext.neo4jService.tryWriteAsync( - ` + + // remove the existing vote + await this._dbContext.neo4jService.tryWriteAsync( + ` MATCH (u:User { userId: $userId })-[r:${relType}]->(p:Post { postId: $postId }) DELETE r `, - { - userId: user.userId, - postId: votePostPayload.postId, - } - ); + { + userId: user.userId, + postId: votePostPayload.postId, + } + ); + + // don't add a new vote if the user is removing their vote (stop) + if ( + (relType === UserToPostRelTypes.UPVOTES && + votePostPayload.voteType === VoteType.UPVOTES) || + (relType === UserToPostRelTypes.DOWN_VOTES && + votePostPayload.voteType === VoteType.DOWN_VOTES) + ) { + return; } } + // add the new vote + const voteProps = new VoteProps({ + votedAt: new Date().getTime(), + }); await this._dbContext.neo4jService.tryWriteAsync( ` MATCH (u:User { userId: $userId }), (p:Post { postId: $postId }) - MERGE (u)-[r:${votePostPayload.voteType}]->(p) + MERGE (u)-[r:${votePostPayload.voteType} { votedAt: $votedAt }]->(p) `, { userId: user.userId, postId: votePostPayload.postId, + + votedAt: voteProps.votedAt, } ); + + const eventType = + votePostPayload.voteType === VoteType.UPVOTES + ? EventTypes.PostGotUpVote + : EventTypes.PostGotDownVote; + + try { + await post.getAuthorUser(); + this._eventEmitter.emit( + eventType, + new PostGotVoteEvent({ + subscriberId: post.authorUser.userId, + postId: post.postId, + username: user.username, + avatar: user.avatar, + }) + ); + } catch (error) { + this._logger.error(error); + } } private getUserFromRequest(): User { const user = this._request.user as User; - if (user === undefined) throw new Error("User not found"); + if (user === undefined) throw new HttpException("User not found", 404); return user; } } diff --git a/src/pusher/controllers/pusher.controller.ts b/src/pusher/controllers/pusher.controller.ts new file mode 100644 index 0000000..725556f --- /dev/null +++ b/src/pusher/controllers/pusher.controller.ts @@ -0,0 +1,54 @@ +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { + ClassSerializerInterceptor, + Controller, + Get, + Inject, + Post, + UseGuards, + UseInterceptors, +} from "@nestjs/common"; +import { _$ } from "../../_domain/injectableTokens"; +import { IPusherService } from "../services/pusher/pusher.service.interface"; +import { AuthGuard } from "@nestjs/passport"; +import { AuthedUser } from "../../auth/decorators/authedUser.param.decorator"; +import { User } from "../../users/models"; +import { IPusherUserPoolService } from "../services/pusherUserPoolServer/pusherUserPool.service.interface"; +import { NotificationStashPoolItem } from "../models/notificationStashPoolItem.interface"; +import { INotificationStashPoolService } from "../services/notificationStashPool/notificationStashPool.service.interface"; +import { auth } from "neo4j-driver"; + +@ApiTags("pusher") +@Controller("pusher") +@ApiBearerAuth() +@UseInterceptors(ClassSerializerInterceptor) +export class PusherController { + private readonly _pusherService: IPusherService; + private readonly _pusherUserPoolService: IPusherUserPoolService; + private readonly _notificationStashPoolService: INotificationStashPoolService; + + constructor( + @Inject(_$.IPusherService) pusherService: IPusherService, + @Inject(_$.IPusherUserPoolService) pusherUserPoolService: IPusherUserPoolService, + @Inject(_$.INotificationStashPoolService) + notificationStashPoolService: INotificationStashPoolService + ) { + this._pusherService = pusherService; + this._pusherUserPoolService = pusherUserPoolService; + this._notificationStashPoolService = notificationStashPoolService; + } + + @Post("/auth") + @UseGuards(AuthGuard("jwt")) + public async authenticate(@AuthedUser() authedUser: User): Promise { + return await this._pusherUserPoolService.addUserToPool(authedUser.userId); // returns poolId + } + + @Get("/notifications/stash") + @UseGuards(AuthGuard("jwt")) + public async getStashedNotifications( + @AuthedUser() authedUser: User + ): Promise { + return await this._notificationStashPoolService.popStashNotifications(authedUser.userId); + } +} diff --git a/src/pusher/models/notificationStashPoolItem.interface.ts b/src/pusher/models/notificationStashPoolItem.interface.ts new file mode 100644 index 0000000..be7504b --- /dev/null +++ b/src/pusher/models/notificationStashPoolItem.interface.ts @@ -0,0 +1,12 @@ +export interface NotificationStashPoolItem { + userId: UUID; + + username?: string; + avatar?: string; + + stashToken: UUID; + + message: string; + + pushedAt: number; +} diff --git a/src/pusher/models/pusherUserPoolItem.interface.ts b/src/pusher/models/pusherUserPoolItem.interface.ts new file mode 100644 index 0000000..8ffd9f6 --- /dev/null +++ b/src/pusher/models/pusherUserPoolItem.interface.ts @@ -0,0 +1,6 @@ +export interface PusherUserPoolItem { + userId: UUID; + poolId: string; + lastAccessedAt: number; + createdAt: number; +} diff --git a/src/pusher/pusher.constant.ts b/src/pusher/pusher.constant.ts new file mode 100644 index 0000000..500256a --- /dev/null +++ b/src/pusher/pusher.constant.ts @@ -0,0 +1,7 @@ +export const envKeys = { + appId: "PUSHER_APP_ID", + key: "PUSHER_KEY", + secret: "PUSHER_SECRET", + cluster: "PUSHER_CLUSTER", + useTLS: "PUSHER_USE_TLS", +}; diff --git a/src/pusher/pusher.module.ts b/src/pusher/pusher.module.ts new file mode 100644 index 0000000..18f6da6 --- /dev/null +++ b/src/pusher/pusher.module.ts @@ -0,0 +1,44 @@ +import { Module } from "@nestjs/common"; +import { _$ } from "../_domain/injectableTokens"; +import { PusherService } from "./services/pusher/pusher.service"; +import { PusherController } from "./controllers/pusher.controller"; +import { PusherUserPoolService } from "./services/pusherUserPoolServer/pusherUserPool.service"; +import { NotificationStashPoolService } from "./services/notificationStashPool/notificationStashPool.service"; +import { NotificationMessageMakerService } from "./services/notificationMessageMaker/notificationMessageMaker.service"; + +@Module({ + providers: [ + { + provide: _$.IPusherService, + useClass: PusherService, + }, + { + provide: _$.IPusherUserPoolService, + useClass: PusherUserPoolService, + }, + { + provide: _$.INotificationStashPoolService, + useClass: NotificationStashPoolService, + }, + { + provide: _$.INotificationMessageMakerService, + useClass: NotificationMessageMakerService, + }, + ], + exports: [ + { + provide: _$.IPusherService, + useClass: PusherService, + }, + { + provide: _$.INotificationStashPoolService, + useClass: NotificationStashPoolService, + }, + { + provide: _$.INotificationMessageMakerService, + useClass: NotificationMessageMakerService, + }, + ], + controllers: [PusherController], +}) +export class PusherModule {} diff --git a/src/pusher/pusher.types.ts b/src/pusher/pusher.types.ts new file mode 100644 index 0000000..add9086 --- /dev/null +++ b/src/pusher/pusher.types.ts @@ -0,0 +1,7 @@ +export enum ChannelTypesEnum { + IGAQ_Notification = "igaq-notification", +} + +export enum PusherEvents { + UserReceivesNotification = "receive-notification", +} diff --git a/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface.ts b/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface.ts new file mode 100644 index 0000000..e76abf5 --- /dev/null +++ b/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.interface.ts @@ -0,0 +1,49 @@ +import { EventTypes } from "../../../_domain/eventTypes"; + +export interface INotificationMessageMakerService { + stashToken: UUID; + + templates: { [key in EventTypes]: (p: object) => string }; + + makeForNewCommentOnPost(p: { + postId: UUID; + commentId: UUID; + username: string; + postTypeName: string; + commentContent: string; + }): string; + + makeForNewCommentOnComment(p: { + postId: UUID; + commentId: UUID; + username: string; + commentContent: string; + }): string; + + makeForCommentGotUpVote(p: { username: string; postId: UUID; commentId: UUID }): string; + + makeForCommentGotDownVote(p: { username: string; postId: UUID; commentId: UUID }): string; + + makeForCommentGotRestricted(p: { commentContent: string; reason: string }): string; + + makeForCommentGotApprovedByModerator(p: { + commentId: UUID; + postId: UUID; + username: string; + }): string; + + makeForCommentGotPinnedByAuthor(p: { + commentId: UUID; + postId: UUID; + commentContent: string; + username: string; + }): string; + + makeForPostGotUpVote(p: { username: string; postId: UUID }): string; + + makeForPostGotDownVote(p: { username: string; postId: UUID }): string; + + makeForPostGotRestricted(p: { postTitle: string; reason: string }): string; + + makeForPostGotApprovedByModerator(p: { username: string; postId: UUID }): string; +} diff --git a/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.ts b/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.ts new file mode 100644 index 0000000..f6f971d --- /dev/null +++ b/src/pusher/services/notificationMessageMaker/notificationMessageMaker.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Scope } from "@nestjs/common"; +import { INotificationMessageMakerService } from "./notificationMessageMaker.service.interface"; +import { EventTypes } from "../../../_domain/eventTypes"; + +@Injectable({ scope: Scope.DEFAULT }) +export class NotificationMessageMakerService implements INotificationMessageMakerService { + public readonly templates = { + [EventTypes.NewCommentOnPost]: (p: { + username: string; + commentId: UUID; + postId: UUID; + postTypeName: string; + commentContent: string; + }) => + `${p.username} replied to your ${p.postTypeName} '(uuid:${this.stashToken}:post:${ + p.postId + }:comm:${p.commentId}:text:${p.commentContent?.slice(0, 20) ?? ""})'`, + [EventTypes.NewCommentOnComment]: (p: { + postId: UUID; + commentId: UUID; + username: string; + commentContent: string; + }) => + `${p.username} replied to your comment '(uuid:${this.stashToken}:post:${ + p.postId + }:comm:${p.commentId}:text:${p.commentContent?.slice(0, 20) ?? ""})'`, + [EventTypes.CommentGotUpVote]: (p: { username: string; postId: UUID; commentId: UUID }) => + `someone up voted your comment (uuid:${this.stashToken}:comm:${p.commentId}:post:${p.postId}:text:check it out!)`, + [EventTypes.CommentGotDownVote]: (p: { username: string; postId: UUID; commentId: UUID }) => + `someone down voted your comment (uuid:${this.stashToken}:comm:${p.commentId}:post:${p.postId}:text:go to comment)`, + [EventTypes.CommentGotRestricted]: (p: { commentContent: string; reason: string }) => + `A Moderator has restricted your comment due to: "${ + p.reason + }". Comment: '${p.commentContent.slice(0, 20)}'"`, + [EventTypes.CommentGotApprovedByModerator]: (p: { + commentId: UUID; + postId: UUID; + username: string; + }) => + `Our moderator, ${p.username}, allowed your comment to be published. (uuid:${this.stashToken}:comm:${p.commentId}:post:${p.postId}:text:go to comment)`, + [EventTypes.CommentGotPinnedByAuthor]: (p: { + commentId: UUID; + postId: UUID; + commentContent: string; + username: string; + }) => + `${p.username} pinned your comment '${p.commentContent.slice(0, 20)}'. (uuid:${ + this.stashToken + }:post:${p.postId}:comm:${p.commentId}:text:Check it out)`, + [EventTypes.PostGotUpVote]: (p: { username: string; postId: UUID }) => + `${p.username} up voted your post (uuid:${this.stashToken}:post:${p.postId}:text:check it out!)`, + [EventTypes.PostGotDownVote]: (p: { username: string; postId: UUID }) => + `someone down voted your post (uuid:${this.stashToken}:post:${p.postId}:text:go to post)`, + [EventTypes.PostGotRestricted]: (p: { postTitle: string; reason: string }) => + `A Moderator has restricted your post due to: "${ + p.reason + }". Post Title: '${p.postTitle.slice(0, 20)}'"`, + [EventTypes.PostGotApprovedByModerator]: (p: { username: string; postId: UUID }) => + `Our moderator, ${p.username}, allowed your post to be published. (uuid:${this.stashToken}:post:${p.postId}:text:go to post)`, + }; + + public stashToken: string; + + public makeForNewCommentOnPost(p: { + postId: UUID; + commentId: UUID; + username: string; + postTypeName: string; + commentContent: string; + }): string { + return this.templates[EventTypes.NewCommentOnPost](p); + } + + public makeForNewCommentOnComment(p: { + postId: UUID; + commentId: UUID; + username: string; + commentContent: string; + }): string { + return this.templates[EventTypes.NewCommentOnComment](p); + } + + public makeForCommentGotUpVote(p: { username: string; postId: UUID; commentId: UUID }): string { + return this.templates[EventTypes.CommentGotUpVote](p); + } + + public makeForCommentGotDownVote(p: { + username: string; + postId: UUID; + commentId: UUID; + }): string { + return this.templates[EventTypes.CommentGotDownVote](p); + } + + public makeForCommentGotRestricted(p: { commentContent: string; reason: string }): string { + return this.templates[EventTypes.CommentGotRestricted](p); + } + + public makeForCommentGotApprovedByModerator(p: { + commentId: UUID; + postId: UUID; + username: string; + }): string { + return this.templates[EventTypes.CommentGotApprovedByModerator](p); + } + + public makeForCommentGotPinnedByAuthor(p: { + commentId: UUID; + postId: UUID; + commentContent: string; + username: string; + }): string { + return this.templates[EventTypes.CommentGotPinnedByAuthor](p); + } + + public makeForPostGotUpVote(p: { username: string; postId: UUID }): string { + return this.templates[EventTypes.PostGotUpVote](p); + } + + public makeForPostGotDownVote(p: { username: string; postId: UUID }): string { + return this.templates[EventTypes.PostGotDownVote](p); + } + + public makeForPostGotRestricted(p: { postTitle: string; reason: string }): string { + return this.templates[EventTypes.PostGotRestricted](p); + } + + public makeForPostGotApprovedByModerator(p: { username: string; postId: UUID }): string { + return this.templates[EventTypes.PostGotApprovedByModerator](p); + } +} diff --git a/src/pusher/services/notificationStashPool/notificationStashPool.service.interface.ts b/src/pusher/services/notificationStashPool/notificationStashPool.service.interface.ts new file mode 100644 index 0000000..d0befe2 --- /dev/null +++ b/src/pusher/services/notificationStashPool/notificationStashPool.service.interface.ts @@ -0,0 +1,15 @@ +import { NotificationStashPoolItem } from "../../models/notificationStashPoolItem.interface"; + +export interface INotificationStashPoolService { + stashNotification( + stashToken: UUID, + userId: UUID, + message: string, + avatar?: string, + username?: string + ): Promise; + + popStashNotifications(userId: UUID): Promise; + + dropStashNotification(userId: UUID): Promise; +} diff --git a/src/pusher/services/notificationStashPool/notificationStashPool.service.ts b/src/pusher/services/notificationStashPool/notificationStashPool.service.ts new file mode 100644 index 0000000..4317b14 --- /dev/null +++ b/src/pusher/services/notificationStashPool/notificationStashPool.service.ts @@ -0,0 +1,52 @@ +import { INotificationStashPoolService } from "./notificationStashPool.service.interface"; +import { Injectable, Scope } from "@nestjs/common"; +import { NotificationStashPoolItem } from "../../models/notificationStashPoolItem.interface"; + +@Injectable({ scope: Scope.DEFAULT }) +export class NotificationStashPoolService implements INotificationStashPoolService { + private readonly notificationStashPool: Map = new Map< + UUID, + NotificationStashPoolItem[] + >(); + + public async stashNotification( + stashToken: UUID, + userId: UUID, + message: string, + avatar?: string, + username?: string + ): Promise { + const createdStash: NotificationStashPoolItem = { + stashToken, // aka notificationId + message, + avatar, + username, + userId, + pushedAt: Date.now(), + }; + + const foundStash = this.notificationStashPool.get(userId); + if (!foundStash) { + this.notificationStashPool.set(userId, [createdStash]); + return createdStash; + } + + this.notificationStashPool.set(userId, [...foundStash, createdStash]); + return createdStash; + } + + public async popStashNotifications(userId: UUID): Promise { + const foundStash = this.notificationStashPool.get(userId); + if (!foundStash) { + return []; + } + + this.dropStashNotification(userId); + + return foundStash; + } + + public async dropStashNotification(userId: UUID): Promise { + return this.notificationStashPool.delete(userId); + } +} diff --git a/src/pusher/services/pusher/pusher.service.interface.ts b/src/pusher/services/pusher/pusher.service.interface.ts new file mode 100644 index 0000000..293a391 --- /dev/null +++ b/src/pusher/services/pusher/pusher.service.interface.ts @@ -0,0 +1,13 @@ +import { TriggerParams } from "pusher"; +import { Response } from "node-fetch"; + +export interface IPusherService { + triggerUser(channel: string, event: string, userId: UUID, data: any): Promise; + + trigger( + channel: string | string[], + event: string, + data: any, + params?: TriggerParams + ): Promise; +} diff --git a/src/pusher/services/pusher/pusher.service.ts b/src/pusher/services/pusher/pusher.service.ts new file mode 100644 index 0000000..6b8046a --- /dev/null +++ b/src/pusher/services/pusher/pusher.service.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable, Scope } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as Pusher from "pusher"; +import { TriggerParams } from "pusher"; +import { envKeys } from "../../pusher.constant"; +import { IPusherService } from "./pusher.service.interface"; +import { IPusherUserPoolService } from "../pusherUserPoolServer/pusherUserPool.service.interface"; +import { _$ } from "../../../_domain/injectableTokens"; +import { Response } from "node-fetch"; + +@Injectable({ scope: Scope.DEFAULT }) +export class PusherService implements IPusherService { + private readonly pusher: Pusher; + + constructor( + private _configService: ConfigService, + @Inject(_$.IPusherUserPoolService) + private readonly _pusherUserPoolService: IPusherUserPoolService + ) { + console.log("connecting to pusher 🇰🇼"); + this.pusher = new Pusher({ + appId: this._configService.get(envKeys.appId), + key: this._configService.get(envKeys.key), + secret: this._configService.get(envKeys.secret), + cluster: this._configService.get(envKeys.cluster), + useTLS: this._configService.get(envKeys.useTLS) ?? false, + }); + } + + public async triggerUser( + channel: string, + event: string, + userId: UUID, + data: any + ): Promise { + const poolItems = await this._pusherUserPoolService.getPoolItemsByUserId(userId); + return await Promise.all( + poolItems.map(poolItem => { + console.log(poolItem); + return this.pusher.trigger(`${poolItem.poolId}-${channel}`, event, data); + }) + ); + } + + public async trigger( + channel: string | string[], + event: string, + data: any, + params?: TriggerParams + ): Promise { + return await this.pusher.trigger(channel, event, data, params); + } +} diff --git a/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.interface.ts b/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.interface.ts new file mode 100644 index 0000000..7829781 --- /dev/null +++ b/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.interface.ts @@ -0,0 +1,11 @@ +import { PusherUserPoolItem } from "../../models/pusherUserPoolItem.interface"; + +export interface IPusherUserPoolService { + addUserToPool(userId: UUID): Promise; + + getPoolItemsByUserId(userId: UUID): Promise; + + getPoolItemByPoolId(poolId: string): Promise; + + removePoolId(poolId: string, userId?: UUID): Promise; +} diff --git a/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.ts b/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.ts new file mode 100644 index 0000000..8cd3416 --- /dev/null +++ b/src/pusher/services/pusherUserPoolServer/pusherUserPool.service.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger, Scope } from "@nestjs/common"; +import { makeStringId } from "../../../_domain/utils"; +import { IPusherUserPoolService } from "./pusherUserPool.service.interface"; +import { PusherUserPoolItem } from "../../models/pusherUserPoolItem.interface"; + +@Injectable({ scope: Scope.DEFAULT }) +export class PusherUserPoolService implements IPusherUserPoolService { + private readonly _logger = new Logger(PusherUserPoolService.name); + + private readonly pusherUserPool: Map = new Map< + string, + PusherUserPoolItem + >(); + private readonly pusherUserIdToPoolIds: Map = new Map(); + + constructor() { + this.maintainPool(); + } + + public async addUserToPool(userId: UUID): Promise { + const poolId = makeStringId(6); + + // don't wait + setTimeout(() => { + this.pusherUserPool.set(poolId, { + poolId, + userId, + lastAccessedAt: Date.now(), + createdAt: Date.now(), + }); + + // add the poolId to the dictionary of userId->poolIds + const poolIdsOfUser = this.pusherUserIdToPoolIds.get(userId); + if (poolIdsOfUser) { + this.pusherUserIdToPoolIds.set(userId, [...poolIdsOfUser, poolId]); + } else { + this.pusherUserIdToPoolIds.set(userId, [poolId]); + } + }); + + return poolId; + } + + public async getPoolItemsByUserId(userId: UUID): Promise { + const foundPoolIds = this.pusherUserIdToPoolIds.get(userId); + if (!foundPoolIds) { + return []; + } + + return ( + await Promise.all(foundPoolIds.map(async poolId => this.getPoolItemByPoolId(poolId))) + ).filter(i => i !== undefined); + } + + public async getPoolItemByPoolId(poolId: string): Promise { + const foundPoolItem = this.pusherUserPool.get(poolId); + if (!foundPoolItem) { + return undefined; + } + + // touch the pool item and update its `lastAccessedAt` for future maintenance. + setTimeout(() => { + this.pusherUserPool.set(poolId, { + poolId, + userId: foundPoolItem.userId, + lastAccessedAt: Date.now(), + createdAt: foundPoolItem.createdAt, + }); + }); + + return foundPoolItem; + } + + public async removePoolId(poolId: string, userId?: UUID): Promise { + // remove it from the userId->poolIds dictionary first. + if (userId) { + const foundPoolIds = this.pusherUserIdToPoolIds.get(userId); + if (foundPoolIds && foundPoolIds.includes(poolId)) { + this.pusherUserIdToPoolIds.set(userId, [ + ...foundPoolIds.filter(pi => pi !== poolId), + ]); + } + } else { + for (const poolIds of this.pusherUserIdToPoolIds.values()) { + if (poolIds.includes(poolId)) { + this.pusherUserIdToPoolIds.set(userId, [ + ...poolIds.filter(pi => pi !== poolId), + ]); + } + } + } + + // then remove it from UserPool + this.pusherUserPool.delete(poolId); + } + + private async maintainPool(): Promise { + setTimeout(async () => { + this.pusherUserPool.forEach((poolItem, poolId) => { + if (Date.now() - poolItem.lastAccessedAt > 30 * 60) { + this._logger.verbose( + `PusherUserPool Maintenance: Removing inactive pool item -> poolId = ${poolItem.poolId}. PoolItem: `, + poolItem + ); + this.removePoolId(poolId); + } + }); + }, 10000); + } +} diff --git a/src/users/controllers/genders.controller.ts b/src/users/controllers/genders.controller.ts new file mode 100644 index 0000000..20315d1 --- /dev/null +++ b/src/users/controllers/genders.controller.ts @@ -0,0 +1,37 @@ +import { + ClassSerializerInterceptor, + Controller, + Get, + Inject, + Param, + ParseUUIDPipe, + UseInterceptors, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { IGenderRepository } from "../repositories/gender/gender.repository.interface"; +import { _$ } from "../../_domain/injectableTokens"; +import { Gender } from "../models"; + +@UseInterceptors(ClassSerializerInterceptor) +@ApiTags("genders") +@ApiBearerAuth() +@Controller("genders") +export class GendersController { + private readonly _genderRepository: IGenderRepository; + + constructor(@Inject(_$.IGenderRepository) genderRepository: IGenderRepository) { + this._genderRepository = genderRepository; + } + + @Get("/") + public async getGenders(): Promise { + return await this._genderRepository.findAll(); + } + + @Get("/:genderId") + public async getGenderById( + @Param("genderId", new ParseUUIDPipe()) genderId: UUID + ): Promise { + return await this._genderRepository.findGenderById(genderId); + } +} diff --git a/src/users/controllers/openness.controller.ts b/src/users/controllers/openness.controller.ts new file mode 100644 index 0000000..32abe13 --- /dev/null +++ b/src/users/controllers/openness.controller.ts @@ -0,0 +1,28 @@ +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Inject, Param, ParseUUIDPipe } from "@nestjs/common"; +import { IOpennessRepository } from "../repositories/openness/openness.repository.interface"; +import { _$ } from "../../_domain/injectableTokens"; +import { Openness } from "../models"; + +@ApiTags("openness") +@ApiBearerAuth() +@Controller("openness") +export class OpennessController { + private readonly _opennessRepository: IOpennessRepository; + + constructor(@Inject(_$.IOpennessRepository) opennessRepository: IOpennessRepository) { + this._opennessRepository = opennessRepository; + } + + @Get("/") + public async getOpennesses(): Promise { + return await this._opennessRepository.findAll(); + } + + @Get("/:opennessId") + public async getOpennessById( + @Param("opennessId", new ParseUUIDPipe()) opennessId: UUID + ): Promise { + return await this._opennessRepository.findOpennessById(opennessId); + } +} diff --git a/src/users/controllers/sexualities.controller.ts b/src/users/controllers/sexualities.controller.ts new file mode 100644 index 0000000..5da8840 --- /dev/null +++ b/src/users/controllers/sexualities.controller.ts @@ -0,0 +1,28 @@ +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Inject, Param, ParseUUIDPipe } from "@nestjs/common"; +import { ISexualityRepository } from "../repositories/sexuality/sexuality.repository.interface"; +import { _$ } from "../../_domain/injectableTokens"; +import { Sexuality } from "../models"; + +@ApiTags("sexualities") +@ApiBearerAuth() +@Controller("sexualities") +export class SexualitiesController { + private readonly _sexualityRepository: ISexualityRepository; + + constructor(@Inject(_$.ISexualityRepository) sexualityRepository: ISexualityRepository) { + this._sexualityRepository = sexualityRepository; + } + + @Get("/") + public async getSexualities(): Promise { + return await this._sexualityRepository.findAll(); + } + + @Get("/:sexualityId") + public async getSexualityById( + @Param("sexualityId", new ParseUUIDPipe()) sexualityId: UUID + ): Promise { + return await this._sexualityRepository.findSexualityById(sexualityId); + } +} diff --git a/src/users/controllers/users.controller.ts b/src/users/controllers/users.controller.ts index ec62b5d..c0da0a3 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/controllers/users.controller.ts @@ -1,4 +1,5 @@ import { + Body, ClassSerializerInterceptor, Controller, Get, @@ -6,6 +7,8 @@ import { Inject, Param, ParseUUIDPipe, + Patch, + Put, UseGuards, UseInterceptors, } from "@nestjs/common"; @@ -15,39 +18,87 @@ import { Roles } from "../../auth/decorators/roles.decorator"; import { RolesGuard } from "../../auth/guards/roles.guard"; import { _$ } from "../../_domain/injectableTokens"; import { Role, User } from "../models"; -import { IUsersRepository } from "../repositories/users/users.repository.interface"; +import { ModerationPayloadDto } from "../../moderation/dtos/moderatorActions"; +import { AuthedUser } from "../../auth/decorators/authedUser.param.decorator"; +import { OptionalJwtAuthGuard } from "../../auth/guards/optionalJwtAuth.guard"; +import { PublicUserDto, SetupProfileDto } from "../dtos"; +import { IProfileSetupService } from "../services/profileSetup/profileSetup.service.interface"; +import { DatabaseContext } from "../../database-access-layer/databaseContext"; +import { IUserHistoryService } from "../services/userHistory/userHistory.service.interface"; +import { IModeratorActionsService } from "src/moderation/services/moderatorActions/moderatorActions.service.interface"; @UseInterceptors(ClassSerializerInterceptor) @ApiTags("users") @ApiBearerAuth() @Controller("users") export class UsersController { - constructor(@Inject(_$.IUsersRepository) private _usersRepository: IUsersRepository) {} + private readonly _dbContext: DatabaseContext; + private readonly _userHistoryService: IUserHistoryService; + private readonly _profileSetup: IProfileSetupService; + private readonly _moderationActions: IModeratorActionsService; + + constructor( + @Inject(_$.IDatabaseContext) dbContext: DatabaseContext, + @Inject(_$.IUserHistoryService) userHistoryService: IUserHistoryService, + @Inject(_$.IProfileSetupService) profileSetupService: IProfileSetupService, + @Inject(_$.IModeratorActionsService) moderatorActionsService: IModeratorActionsService + ) { + this._dbContext = dbContext; + this._userHistoryService = userHistoryService; + this._profileSetup = profileSetupService; + this._moderationActions = moderatorActionsService; + } @Get() - @Roles(Role.ADMIN) + @Roles(Role.MODERATOR) @UseGuards(AuthGuard("jwt"), RolesGuard) - public async index(): Promise { - const users = await this._usersRepository.findAll(); + public async index(): Promise { + const users = await this._dbContext.Users.findAll(); const decoratedUsers = users.map(user => user.toJSON()); return await Promise.all(decoratedUsers); } @Get(":userId") - @Roles(Role.ADMIN) + @Roles(Role.MODERATOR) @UseGuards(AuthGuard("jwt"), RolesGuard) - public async getUserById( - @Param("userId", new ParseUUIDPipe()) userId: string - ): Promise { - const user = await this._usersRepository.findUserById(userId); + public async getUserById(@Param("userId", new ParseUUIDPipe()) userId: string): Promise { + const user = await this._dbContext.Users.findUserById(userId); if (user === undefined) throw new HttpException("User not found", 404); return user; } @Get("/username/:username") - public async getUserByUsername(@Param("username") username: string): Promise { - const user = await this._usersRepository.findUserByUsername(username); + @UseGuards(OptionalJwtAuthGuard) + public async getUserByUsername( + @Param("username") username: string, + @AuthedUser() authedUser?: User + ): Promise { + const user = await this._dbContext.Users.findUserByUsername(username); if (user === undefined) throw new HttpException("User not found", 404); - return user; + + // if the found user is the same as the authed user, return the full user object + if (authedUser?.userId === user.userId) { + return await user.toJSON(); + } + return PublicUserDto.fromUser(await user.toJSON()); + } + + @Get("/:username/history/posts") + @UseGuards(OptionalJwtAuthGuard) + public async getPostHistoryOfUserByUsername( + @Param("username") username: string, + @AuthedUser() authedUser?: User + ) { + const posts = await this._userHistoryService.getPostsHistoryByUsername(username); + const decoratedPosts = posts.map(post => + post.toJSON({ authenticatedUserId: authedUser?.userId ?? undefined }) + ); + return await Promise.all(decoratedPosts); + } + + @Put("/profileSetup") + @UseGuards(AuthGuard("jwt")) + public async profileSetupSubmit(@Body() setupProfileDto: SetupProfileDto): Promise { + await this._profileSetup.setupProfile(setupProfileDto); } } diff --git a/src/users/dtos/index.ts b/src/users/dtos/index.ts index c323612..bc5a050 100644 --- a/src/users/dtos/index.ts +++ b/src/users/dtos/index.ts @@ -1 +1,2 @@ -export { PublicUserDto } from "./publicUser.dto"; +export { PublicUserDto } from "./publicUser.dto"; +export { SetupProfileDto } from "./setupProfile.dto"; diff --git a/src/users/dtos/publicUser.dto.ts b/src/users/dtos/publicUser.dto.ts index 8d8f1f3..2729825 100644 --- a/src/users/dtos/publicUser.dto.ts +++ b/src/users/dtos/publicUser.dto.ts @@ -1,48 +1,55 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty } from "class-validator"; -import { Gender } from "../models/gender"; -import { Sexuality } from "../models/sexuality"; -import { AvatarAscii, AvatarUrl } from "../models/user"; -import { User } from "../models/user"; - -export class PublicUserDto { - @ApiProperty({ type: String, format: "uuid" }) - @IsNotEmpty() - userId: string; - - @ApiProperty({ type: String }) - @IsNotEmpty() - avatar: AvatarAscii | AvatarUrl; - - @ApiProperty({ type: String }) - @IsNotEmpty() - username: string; - - @ApiProperty({ type: Number }) - @IsNotEmpty() - level: number; - - @ApiProperty({ type: Sexuality }) - @IsNotEmpty() - sexuality: Nullable; - - @ApiProperty({ type: Gender }) - @IsNotEmpty() - gender: Nullable; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } - - static fromUser(user: User): PublicUserDto { - return new PublicUserDto({ - userId: user.userId, - username: user.username, - avatar: user.avatar || null, - level: user.level, - sexuality: user.sexuality || null, - gender: user.gender || null, - }); - } -} - +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty } from "class-validator"; +import { Gender, Openness, Sexuality, User } from "../models"; +import { UserAvatar } from "../models/user"; + +export class PublicUserDto { + @ApiProperty({ type: String, format: "uuid" }) + @IsNotEmpty() + userId: UUID; + + @ApiProperty({ type: String }) + @IsNotEmpty() + avatar: UserAvatar; + + @ApiProperty({ type: String }) + @IsNotEmpty() + bio: string; + + @ApiProperty({ type: String }) + @IsNotEmpty() + username: string; + + @ApiProperty({ type: Number }) + @IsNotEmpty() + level: number; + + @ApiProperty({ type: Sexuality }) + @IsNotEmpty() + sexuality: Nullable; + + @ApiProperty({ type: Gender }) + @IsNotEmpty() + gender: Nullable; + + @ApiProperty({ type: Openness }) + @IsNotEmpty() + openness: Nullable; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } + + static fromUser(user: User): PublicUserDto { + return new PublicUserDto({ + userId: user.userId, + username: user.username, + avatar: user.avatar || null, + bio: user.bio || null, + level: user.level, + sexuality: user.sexuality && !user.isSexualityPrivate ? user.sexuality : null, + gender: user.gender && !user.isGenderPrivate ? user.gender : null, + openness: user.openness && !user.isOpennessPrivate ? user.openness : null, + }); + } +} diff --git a/src/users/dtos/setupProfile.dto.ts b/src/users/dtos/setupProfile.dto.ts new file mode 100644 index 0000000..d84bacd --- /dev/null +++ b/src/users/dtos/setupProfile.dto.ts @@ -0,0 +1,38 @@ +import { IsBoolean, IsString, IsUUID } from "class-validator"; +import { UserAvatar } from "../models/user"; +import { ApiProperty } from "@nestjs/swagger"; + +export class SetupProfileDto { + @ApiProperty({ type: String }) + @IsString() + bio: string; + + @ApiProperty({ type: String }) + @IsString() + avatar: UserAvatar; + + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + genderId: UUID; + @ApiProperty({ type: Boolean }) + @IsBoolean() + isGenderPrivate: boolean; + + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + sexualityId: UUID; + @ApiProperty({ type: Boolean }) + @IsBoolean() + isSexualityOpen: boolean; + + @ApiProperty({ type: String, format: "uuid" }) + @IsUUID() + opennessId: UUID; + @ApiProperty({ type: Boolean }) + @IsBoolean() + isOpennessPrivate: boolean; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/gender.ts b/src/users/models/gender.ts index 3885346..9604dfc 100644 --- a/src/users/models/gender.ts +++ b/src/users/models/gender.ts @@ -1,22 +1,22 @@ import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; -import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsUUID } from "class-validator"; @Labels("Gender") export class Gender { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - genderId: string; + @IsUUID() + genderId: UUID; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() genderName: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() genderPronouns: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() genderFlagSvg: string; constructor(partial?: Partial) { diff --git a/src/users/models/openness.ts b/src/users/models/openness.ts index e97fd9e..6614ad9 100644 --- a/src/users/models/openness.ts +++ b/src/users/models/openness.ts @@ -1,18 +1,18 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; +import { IsNumber, IsString, IsUUID } from "class-validator"; @Labels("Openness") export class Openness { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - opennessId: string; + @IsUUID() + opennessId: UUID; - @ApiProperty({ type: Number }) @NodeProperty() + @IsNumber() opennessLevel: number; - @ApiProperty({ type: Number }) @NodeProperty() + @IsString() opennessDescription: string; constructor(partial?: Partial) { diff --git a/src/users/models/role.ts b/src/users/models/role.ts index 5afb43e..29091a0 100644 --- a/src/users/models/role.ts +++ b/src/users/models/role.ts @@ -1,5 +1,5 @@ export enum Role { USER, - ADMIN, MODERATOR, + ADMIN, } diff --git a/src/users/models/sexuality.ts b/src/users/models/sexuality.ts index 7661153..f7c49fc 100644 --- a/src/users/models/sexuality.ts +++ b/src/users/models/sexuality.ts @@ -1,18 +1,18 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; +import { IsString, IsUUID } from "class-validator"; @Labels("Sexuality") export class Sexuality { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - sexualityId: string; + @IsUUID() + sexualityId: UUID; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() sexualityName: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() sexualityFlagSvg: string; constructor(partial?: Partial) { diff --git a/src/users/models/toComment/authored.props.ts b/src/users/models/toComment/authored.props.ts index d0a07c5..7d020b6 100644 --- a/src/users/models/toComment/authored.props.ts +++ b/src/users/models/toComment/authored.props.ts @@ -1,11 +1,11 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsBoolean, IsNumber } from "class-validator"; export class AuthoredProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() authoredAt: number; - @ApiProperty({ type: Boolean }) + @IsBoolean() anonymously: boolean; constructor(partial?: Partial) { diff --git a/src/users/models/toComment/downVotes.props.ts b/src/users/models/toComment/downVotes.props.ts index 4b728a7..4048e31 100644 --- a/src/users/models/toComment/downVotes.props.ts +++ b/src/users/models/toComment/downVotes.props.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber } from "class-validator"; export class DownVotesProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() downVotedAt: number; constructor(partial?: Partial) { diff --git a/src/users/models/toComment/reported.props.ts b/src/users/models/toComment/reported.props.ts index d3f9de5..6088f6b 100644 --- a/src/users/models/toComment/reported.props.ts +++ b/src/users/models/toComment/reported.props.ts @@ -1,11 +1,11 @@ import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; -import { User } from "../user"; +import { IsNumber, IsString, IsUUID } from "class-validator"; export class ReportedProps implements RelationshipProps { - reportedBy: User; - + @IsNumber() reportedAt: number; + @IsString() reason: string; constructor(partial?: Partial) { diff --git a/src/users/models/toComment/upVotes.props.ts b/src/users/models/toComment/upVotes.props.ts index e732b66..f5a98c6 100644 --- a/src/users/models/toComment/upVotes.props.ts +++ b/src/users/models/toComment/upVotes.props.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber } from "class-validator"; export class UpVotesProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() upVotedAt: number; constructor(partial?: Partial) { diff --git a/src/users/models/toGender/hasGender.props.ts b/src/users/models/toGender/hasGender.props.ts new file mode 100644 index 0000000..305de69 --- /dev/null +++ b/src/users/models/toGender/hasGender.props.ts @@ -0,0 +1,11 @@ +import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsBoolean } from "class-validator"; + +export class HasGenderProps implements RelationshipProps { + @IsBoolean() + isPrivate: boolean; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/toGender/index.ts b/src/users/models/toGender/index.ts index b8798d0..4578753 100644 --- a/src/users/models/toGender/index.ts +++ b/src/users/models/toGender/index.ts @@ -1,3 +1,5 @@ +export { HasGenderProps } from "./hasGender.props"; + export enum UserToGenderRelTypes { HAS_GENDER = "HAS_GENDER", } diff --git a/src/users/models/toOpenness/hasOpenness.props.ts b/src/users/models/toOpenness/hasOpenness.props.ts new file mode 100644 index 0000000..4cb3754 --- /dev/null +++ b/src/users/models/toOpenness/hasOpenness.props.ts @@ -0,0 +1,11 @@ +import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsBoolean } from "class-validator"; + +export class HasOpennessProps implements RelationshipProps { + @IsBoolean() + isPrivate: boolean; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/toOpenness/index.ts b/src/users/models/toOpenness/index.ts index ff8e988..94751ec 100644 --- a/src/users/models/toOpenness/index.ts +++ b/src/users/models/toOpenness/index.ts @@ -1,3 +1,5 @@ +export { HasOpennessProps } from "./hasOpenness.props"; + export enum UserToOpennessRelTypes { HAS_OPENNESS_LEVEL_OF = "HAS_OPENNESS_LEVEL_OF", } diff --git a/src/users/models/toPost/authored.props.ts b/src/users/models/toPost/authored.props.ts index d0a07c5..7d020b6 100644 --- a/src/users/models/toPost/authored.props.ts +++ b/src/users/models/toPost/authored.props.ts @@ -1,11 +1,11 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsBoolean, IsNumber } from "class-validator"; export class AuthoredProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() authoredAt: number; - @ApiProperty({ type: Boolean }) + @IsBoolean() anonymously: boolean; constructor(partial?: Partial) { diff --git a/src/users/models/toPost/downVotes.props.ts b/src/users/models/toPost/downVotes.props.ts deleted file mode 100644 index 4b728a7..0000000 --- a/src/users/models/toPost/downVotes.props.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; - -export class DownVotesProps implements RelationshipProps { - @ApiProperty({ type: Number }) - downVotedAt: number; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/users/models/toPost/favorites.props.ts b/src/users/models/toPost/favorites.props.ts index b478e13..27501ed 100644 --- a/src/users/models/toPost/favorites.props.ts +++ b/src/users/models/toPost/favorites.props.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber } from "class-validator"; export class FavoritesProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() favoritedAt: number; constructor(partial?: Partial) { diff --git a/src/users/models/toPost/index.ts b/src/users/models/toPost/index.ts index d3479bf..09f4dc1 100644 --- a/src/users/models/toPost/index.ts +++ b/src/users/models/toPost/index.ts @@ -1,7 +1,6 @@ export { AuthoredProps } from "./authored.props"; export { ReadProps } from "./read.props"; -export { UpVotesProps } from "./upVotes.props"; -export { DownVotesProps } from "./downVotes.props"; +export { VoteProps } from "./vote.props"; export { FavoritesProps } from "./favorites.props"; export { ReportedProps } from "./reported.props"; diff --git a/src/users/models/toPost/read.props.ts b/src/users/models/toPost/read.props.ts index a6f67be..287aa71 100644 --- a/src/users/models/toPost/read.props.ts +++ b/src/users/models/toPost/read.props.ts @@ -1,8 +1,8 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber } from "class-validator"; export class ReadProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() readAt: number; constructor(partial?: Partial) { diff --git a/src/users/models/toPost/reported.props.ts b/src/users/models/toPost/reported.props.ts index d3f9de5..6088f6b 100644 --- a/src/users/models/toPost/reported.props.ts +++ b/src/users/models/toPost/reported.props.ts @@ -1,11 +1,11 @@ import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; -import { User } from "../user"; +import { IsNumber, IsString, IsUUID } from "class-validator"; export class ReportedProps implements RelationshipProps { - reportedBy: User; - + @IsNumber() reportedAt: number; + @IsString() reason: string; constructor(partial?: Partial) { diff --git a/src/users/models/toPost/upVotes.props.ts b/src/users/models/toPost/upVotes.props.ts deleted file mode 100644 index e732b66..0000000 --- a/src/users/models/toPost/upVotes.props.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; - -export class UpVotesProps implements RelationshipProps { - @ApiProperty({ type: Number }) - upVotedAt: number; - - constructor(partial?: Partial) { - Object.assign(this, partial); - } -} diff --git a/src/users/models/toPost/vote.props.ts b/src/users/models/toPost/vote.props.ts new file mode 100644 index 0000000..c209c03 --- /dev/null +++ b/src/users/models/toPost/vote.props.ts @@ -0,0 +1,11 @@ +import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber } from "class-validator"; + +export class VoteProps implements RelationshipProps { + @IsNumber() + votedAt: number; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/toSelf/gotBanned.props.ts b/src/users/models/toSelf/gotBanned.props.ts new file mode 100644 index 0000000..406df2d --- /dev/null +++ b/src/users/models/toSelf/gotBanned.props.ts @@ -0,0 +1,18 @@ +import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNotEmpty, IsNumber, IsString, IsUUID } from "class-validator"; + +export class GotBannedProps implements RelationshipProps { + @IsNumber() + bannedAt: number; + + @IsUUID() + moderatorId: UUID; + + @IsString() + @IsNotEmpty() + reason: string; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/toSelf/index.ts b/src/users/models/toSelf/index.ts index 80e8e05..0f4c650 100644 --- a/src/users/models/toSelf/index.ts +++ b/src/users/models/toSelf/index.ts @@ -1,5 +1,8 @@ export { WasOffendingProps } from "./wasOffending.props"; +export { GotBannedProps } from "./gotBanned.props"; export enum UserToSelfRelTypes { WAS_OFFENDING = "WAS_OFFENDING", + GOT_BANNED = "GOT_BANNED", + PREVIOUSLY_BANNED = "PREVIOUSLY_BANNED", } diff --git a/src/users/models/toSelf/wasOffending.props.ts b/src/users/models/toSelf/wasOffending.props.ts index e56dfd5..f10e0c2 100644 --- a/src/users/models/toSelf/wasOffending.props.ts +++ b/src/users/models/toSelf/wasOffending.props.ts @@ -1,14 +1,14 @@ -import { ApiProperty } from "@nestjs/swagger"; import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsNumber, IsString } from "class-validator"; export class WasOffendingProps implements RelationshipProps { - @ApiProperty({ type: Number }) + @IsNumber() timestamp: number; - @ApiProperty({ type: String }) + @IsString() userContent: string; - @ApiProperty({ type: Number }) + @IsNumber() autoModConfidenceLevel: number; constructor(partial?: Partial) { diff --git a/src/users/models/toSexuality/hasSexuality.props.ts b/src/users/models/toSexuality/hasSexuality.props.ts new file mode 100644 index 0000000..b3ee058 --- /dev/null +++ b/src/users/models/toSexuality/hasSexuality.props.ts @@ -0,0 +1,11 @@ +import { RelationshipProps } from "../../../neo4j/neo4j.helper.types"; +import { IsBoolean } from "class-validator"; + +export class HasSexualityProps implements RelationshipProps { + @IsBoolean() + isPrivate: boolean; + + constructor(partial?: Partial) { + Object.assign(this, partial); + } +} diff --git a/src/users/models/toSexuality/index.ts b/src/users/models/toSexuality/index.ts index 7fd45e1..05c4cae 100644 --- a/src/users/models/toSexuality/index.ts +++ b/src/users/models/toSexuality/index.ts @@ -1,3 +1,5 @@ +export { HasSexualityProps } from "./hasSexuality.props"; + export enum UserToSexualityRelTypes { HAS_SEXUALITY = "HAS_SEXUALITY", } diff --git a/src/users/models/user.spec.ts b/src/users/models/user.spec.ts index 139c6cc..5fb45e8 100644 --- a/src/users/models/user.spec.ts +++ b/src/users/models/user.spec.ts @@ -43,7 +43,7 @@ describe("Post Model Unit Test", () => { describe("given a post instance", () => { beforeAll(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("instance must exist", async () => { @@ -52,7 +52,7 @@ describe("Post Model Unit Test", () => { describe("given user.getAuthoredPosts() called", () => { beforeEach(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("should return an array", async () => { @@ -72,7 +72,7 @@ describe("Post Model Unit Test", () => { describe("given user.getFavoritePosts() called", () => { beforeEach(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("should return an array of proper objects", async () => { @@ -94,7 +94,7 @@ describe("Post Model Unit Test", () => { describe("given user.getSexuality() called", () => { beforeEach(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("should return an object with proper props", async () => { @@ -108,7 +108,7 @@ describe("Post Model Unit Test", () => { describe("given user.getGender() called", () => { beforeEach(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("should return an object with proper props", async () => { @@ -123,7 +123,7 @@ describe("Post Model Unit Test", () => { describe("given user.getOpenness() called", () => { beforeEach(async () => { - user = await usersRepository.findUserById("5c0f145b-ffad-4881-8ee6-7647c3c1b695"); + user = await usersRepository.findUserById("a59437f4-ea62-4a15-a4e6-621b04af74d6"); }); it("should return an object with proper props", async () => { diff --git a/src/users/models/user.ts b/src/users/models/user.ts index cb22d73..6432347 100644 --- a/src/users/models/user.ts +++ b/src/users/models/user.ts @@ -1,4 +1,3 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Exclude } from "class-transformer"; import { Comment } from "../../comments/models"; import { Labels, NodeProperty } from "../../neo4j/neo4j.decorators"; @@ -15,83 +14,115 @@ import { Openness } from "./openness"; import { Role } from "./role"; import { Sexuality } from "./sexuality"; import { AuthoredProps as UserToCommentAuthoredProps, UserToCommentRelTypes } from "./toComment"; -import { UserToGenderRelTypes } from "./toGender"; -import { UserToOpennessRelTypes } from "./toOpenness"; +import { HasGenderProps, UserToGenderRelTypes } from "./toGender"; +import { HasOpennessProps, UserToOpennessRelTypes } from "./toOpenness"; import { AuthoredProps, FavoritesProps, UserToPostRelTypes } from "./toPost"; -import { UserToSelfRelTypes, WasOffendingProps } from "./toSelf"; -import { UserToSexualityRelTypes } from "./toSexuality"; +import { GotBannedProps, UserToSelfRelTypes, WasOffendingProps } from "./toSelf"; +import { HasSexualityProps, UserToSexualityRelTypes } from "./toSexuality"; +import { + IsArray, + IsBoolean, + IsEnum, + IsInstance, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; -export type AvatarUrl = string; -export type AvatarAscii = string; +type AvatarUrl = string; +type AvatarAscii = string; +export type UserAvatar = AvatarUrl | AvatarAscii; @Labels("User") export class User extends Model { - @ApiProperty({ type: String, format: "uuid" }) @NodeProperty() - userId: string; + @IsUUID() + userId: UUID; - @ApiProperty({ type: Date }) @NodeProperty() + @IsNumber() createdAt: number; - @ApiProperty({ type: Date }) @NodeProperty() + @IsNumber() updatedAt: number; - @ApiProperty({ type: String }) @NodeProperty() - avatar: AvatarAscii | AvatarUrl; + @IsString() + avatar: UserAvatar; + + @NodeProperty() + @IsString() + bio: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() email: string; - @ApiProperty({ type: Boolean }) @NodeProperty() + @IsBoolean() emailVerified: boolean; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() + @IsOptional() phoneNumber: Nullable; - @ApiProperty({ type: Boolean }) @NodeProperty() + @IsBoolean() phoneNumberVerified: boolean; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() username: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() normalizedUsername: string; - @ApiProperty({ type: String }) @NodeProperty() + @IsString() @Exclude() passwordHash: string; - @ApiProperty({ type: Number }) @NodeProperty() + @IsNumber() level: number; - @ApiProperty({ type: Role }) + @IsEnum(Role) roles: Role[]; - @ApiProperty({ type: Post, isArray: true }) + @IsOptional() posts: RichRelatedEntities; - @ApiProperty({ type: Comment, isArray: true }) + @IsOptional() comments: RichRelatedEntities; - @ApiProperty({ type: Sexuality }) + @IsInstance(Sexuality) + @IsOptional() sexuality: Nullable; + @IsBoolean() + @IsOptional() + isSexualityPrivate: boolean; - @ApiProperty({ type: Gender }) + @IsInstance(Gender) + @IsOptional() gender: Nullable; + @IsBoolean() + @IsOptional() + isGenderPrivate: boolean; - @ApiProperty({ type: Openness }) + @IsInstance(Openness) + @IsOptional() openness: Nullable; + @IsBoolean() + @IsOptional() + isOpennessPrivate: boolean; - @ApiProperty({ type: WasOffendingProps, isArray: true }) - @Exclude() - wasOffendingRecords: WasOffendingProps[] = []; + @IsArray() + @IsOptional() + wasOffendingRecords: WasOffendingProps[]; + + @IsInstance(GotBannedProps) + @IsOptional() + gotBannedProps: Nullable; constructor(partial?: Partial, neo4jService?: Neo4jService) { super(neo4jService); @@ -109,6 +140,29 @@ export class User extends Model { return { ...this }; } + /** + * Checks with the database if the user has a GOT_BANNED relationship, and if it has, it will get its properties + * and assigns it to the instance's .gotBannedProps property. + * If the user has no GOT_BANNED relationship, it will assign null to the instance's .gotBannedProps property. + */ + public async getGotBannedProps(): Promise> { + const queryResult = await this.neo4jService.tryReadAsync( + ` + MATCH (u:User { userId: $userId})-[r:${UserToSelfRelTypes.GOT_BANNED}]-(u) + RETURN r + `, + { + userId: this.userId, + } + ); + if (queryResult.records.length === 0) { + this.gotBannedProps = null; + return null; + } + this.gotBannedProps = new GotBannedProps(queryResult.records[0].get("r").properties); + return this.gotBannedProps; + } + public async addWasOffendingRecord(record: WasOffendingProps): Promise { await this.neo4jService.tryWriteAsync( ` @@ -207,7 +261,9 @@ export class User extends Model { if (queryResult.records.length === 0) { return null; } + const hasSexualityProps = new HasSexualityProps(queryResult.records[0].get("r").properties); this.sexuality = new Sexuality(queryResult.records[0].get("s").properties); + this.isSexualityPrivate = hasSexualityProps.isPrivate || false; return this.sexuality; } @@ -261,7 +317,9 @@ export class User extends Model { if (queryResult.records.length === 0) { return null; } + const hasGenderProps = new HasGenderProps(queryResult.records[0].get("r").properties); this.gender = new Gender(queryResult.records[0].get("g").properties); + this.isGenderPrivate = hasGenderProps.isPrivate || false; return this.gender; } @@ -278,7 +336,9 @@ export class User extends Model { if (queryResult.records.length === 0) { return null; } + const hasOpennessProps = new HasOpennessProps(queryResult.records[0].get("r").properties); this.openness = new Openness(queryResult.records[0].get("o").properties); + this.isOpennessPrivate = hasOpennessProps.isPrivate || false; return this.openness; } } diff --git a/src/users/repositories/gender/gender.repository.interface.ts b/src/users/repositories/gender/gender.repository.interface.ts index 08f7021..16498dc 100644 --- a/src/users/repositories/gender/gender.repository.interface.ts +++ b/src/users/repositories/gender/gender.repository.interface.ts @@ -3,11 +3,11 @@ import { Gender } from "../../models"; export interface IGenderRepository { findAll(): Promise; - findGenderById(genderId: string): Promise; + findGenderById(genderId: UUID): Promise; addGender(gender: Gender): Promise; updateGender(gender: Gender): Promise; - deleteGender(genderId: string): Promise; + deleteGender(genderId: UUID): Promise; } diff --git a/src/users/repositories/gender/gender.repository.ts b/src/users/repositories/gender/gender.repository.ts index c535513..7d42b98 100644 --- a/src/users/repositories/gender/gender.repository.ts +++ b/src/users/repositories/gender/gender.repository.ts @@ -8,19 +8,19 @@ export class GenderRepository implements IGenderRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allGenders = await this._neo4jService.read(`MATCH (g:Gender) RETURN g`, {}); + const allGenders = await this._neo4jService.tryReadAsync(`MATCH (g:Gender) RETURN g`, {}); const records = allGenders.records; if (records.length === 0) return []; return records.map(record => new Gender(record.get("g").properties)); } - public async findGenderById(genderId: string): Promise { - const gender = await this._neo4jService.read( + public async findGenderById(genderId: UUID): Promise { + const gender = await this._neo4jService.tryReadAsync( `MATCH (g:Gender) WHERE g.genderId = $genderId RETURN g`, { genderId: genderId } ); if (gender.records.length === 0) return undefined; - return new Gender(gender.records[0].get("s").properties); + return new Gender(gender.records[0].get("g").properties); } public async addGender(gender: Gender): Promise { @@ -46,9 +46,7 @@ export class GenderRepository implements IGenderRepository { } ); - const addedGender = await this.findGenderById(gender.genderId ?? genderId); - - return addedGender; + return await this.findGenderById(gender.genderId ?? genderId); } public async updateGender(gender: Gender): Promise { @@ -72,7 +70,7 @@ export class GenderRepository implements IGenderRepository { ); } - public async deleteGender(genderId: string): Promise { + public async deleteGender(genderId: UUID): Promise { await this._neo4jService.tryWriteAsync( ` MATCH (g:Gender) WHERE g.genderId = $genderId diff --git a/src/users/repositories/openness/openness.repository.interface.ts b/src/users/repositories/openness/openness.repository.interface.ts index ed8ed51..8736ba2 100644 --- a/src/users/repositories/openness/openness.repository.interface.ts +++ b/src/users/repositories/openness/openness.repository.interface.ts @@ -3,11 +3,11 @@ import { Openness } from "../../models"; export interface IOpennessRepository { findAll(): Promise; - findOpennessById(opennessId: string): Promise; + findOpennessById(opennessId: UUID): Promise; addOpenness(openness: Openness): Promise; updateOpenness(openness: Openness): Promise; - deleteOpenness(opennessId: string): Promise; + deleteOpenness(opennessId: UUID): Promise; } diff --git a/src/users/repositories/openness/openness.repository.ts b/src/users/repositories/openness/openness.repository.ts index 8014e72..c556177 100644 --- a/src/users/repositories/openness/openness.repository.ts +++ b/src/users/repositories/openness/openness.repository.ts @@ -8,14 +8,17 @@ export class OpennessRepository implements IOpennessRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allOpenness = await this._neo4jService.read(`MATCH (o:Openness) RETURN o`, {}); + const allOpenness = await this._neo4jService.tryReadAsync( + `MATCH (o:Openness) RETURN o`, + {} + ); const records = allOpenness.records; if (records.length === 0) return []; return records.map(record => new Openness(record.get("o").properties)); } - public async findOpennessById(opennessId: string): Promise { - const openness = await this._neo4jService.read( + public async findOpennessById(opennessId: UUID): Promise { + const openness = await this._neo4jService.tryReadAsync( `MATCH (o:Openness) WHERE o.opennessId = $opennessId RETURN o`, { opennessId: opennessId } ); @@ -66,7 +69,7 @@ export class OpennessRepository implements IOpennessRepository { ); } - public async deleteOpenness(opennessId: string): Promise { + public async deleteOpenness(opennessId: UUID): Promise { await this._neo4jService.tryWriteAsync( ` MATCH (o:Openness) WHERE o.opennessId = $opennessId diff --git a/src/users/repositories/sexuality/sexuality.repository.interface.ts b/src/users/repositories/sexuality/sexuality.repository.interface.ts index c44b85b..2368979 100644 --- a/src/users/repositories/sexuality/sexuality.repository.interface.ts +++ b/src/users/repositories/sexuality/sexuality.repository.interface.ts @@ -3,11 +3,11 @@ import { Sexuality } from "../../models"; export interface ISexualityRepository { findAll(): Promise; - findSexualityById(sexualityId: string): Promise; + findSexualityById(sexualityId: UUID): Promise; addSexuality(sexuality: Sexuality): Promise; updateSexuality(sexuality: Sexuality): Promise; - deleteSexuality(sexualityId: string): Promise; + deleteSexuality(sexualityId: UUID): Promise; } diff --git a/src/users/repositories/sexuality/sexuality.repository.ts b/src/users/repositories/sexuality/sexuality.repository.ts index 2de25d1..47aa42c 100644 --- a/src/users/repositories/sexuality/sexuality.repository.ts +++ b/src/users/repositories/sexuality/sexuality.repository.ts @@ -8,14 +8,17 @@ export class SexualityRepository implements ISexualityRepository { constructor(@Inject(Neo4jService) private _neo4jService: Neo4jService) {} public async findAll(): Promise { - const allSexualities = await this._neo4jService.read(`MATCH (s:Sexuality) RETURN s`, {}); + const allSexualities = await this._neo4jService.tryReadAsync( + `MATCH (s:Sexuality) RETURN s`, + {} + ); const records = allSexualities.records; if (records.length === 0) return []; return records.map(record => new Sexuality(record.get("s").properties)); } - public async findSexualityById(sexualityId: string): Promise { - const sexuality = await this._neo4jService.read( + public async findSexualityById(sexualityId: UUID): Promise { + const sexuality = await this._neo4jService.tryReadAsync( `MATCH (s:Sexuality) WHERE s.sexualityId = $sexualityId RETURN s`, { sexualityId: sexualityId } ); @@ -66,7 +69,7 @@ export class SexualityRepository implements ISexualityRepository { ); } - public async deleteSexuality(sexualityId: string): Promise { + public async deleteSexuality(sexualityId: UUID): Promise { await this._neo4jService.tryWriteAsync( ` MATCH (s:Sexuality) WHERE s.sexualityId = $sexualityId diff --git a/src/users/repositories/users/users.repository.interface.ts b/src/users/repositories/users/users.repository.interface.ts index ccabd8e..16810f0 100644 --- a/src/users/repositories/users/users.repository.interface.ts +++ b/src/users/repositories/users/users.repository.interface.ts @@ -1,4 +1,8 @@ import { User } from "../../models"; +import { HasGenderProps } from "../../models/toGender"; +import { HasOpennessProps } from "../../models/toOpenness"; +import { HasSexualityProps } from "../../models/toSexuality"; +import { GotBannedProps } from "../../models/toSelf"; export interface IUsersRepository { findAll(): Promise; @@ -7,11 +11,47 @@ export interface IUsersRepository { findUserByEmail(email: string): Promise; - findUserById(userId: string): Promise; + findUserById(userId: UUID): Promise; addUser(user: User): Promise; updateUser(user: User): Promise; - deleteUser(userId: string): Promise; + deleteUser(userId: UUID): Promise; + + connectUserWithSexuality( + userId: UUID, + sexualityId: UUID, + hasSexualityProps: HasSexualityProps + ): Promise; + detachUserWithSexuality(userId: UUID): Promise; + updateRelationshipPropsOfHasSexuality( + userId: UUID, + hasSexualityProps: HasSexualityProps + ): Promise; + + connectUserWithGender( + userId: UUID, + genderId: UUID, + hasGenderProps: HasGenderProps + ): Promise; + detachUserWithGender(userId: UUID): Promise; + updateRelationshipPropsOfHasGender(userId: UUID, hasGenderProps: HasGenderProps): Promise; + + connectUserWithOpenness( + userId: UUID, + opennessId: UUID, + hasOpennessProps: HasOpennessProps + ): Promise; + detachUserWithOpenness(userId: UUID): Promise; + updateRelationshipPropsOfHasOpenness( + userId: UUID, + hasGenderProps: HasGenderProps + ): Promise; + + banUser(userId: UUID, banProps: GotBannedProps): Promise; + + unbanUser(userId: UUID): Promise; + + addPreviouslyBanned(userId: UUID, banProps: GotBannedProps): Promise; } diff --git a/src/users/repositories/users/users.repository.test.spec.ts b/src/users/repositories/users/users.repository.test.spec.ts index cd73bc4..1a0a30a 100644 --- a/src/users/repositories/users/users.repository.test.spec.ts +++ b/src/users/repositories/users/users.repository.test.spec.ts @@ -11,6 +11,8 @@ import { Neo4jService } from "../../../neo4j/services/neo4j.service"; import { v4 as uuidv4 } from "uuid"; import { _$ } from "../../../_domain/injectableTokens"; +const userIdToFind = "a59437f4-ea62-4a15-a4e6-621b04af74d6"; + describe("UsersRepository", () => { let usersRepository: IUsersRepository; let neo4jSeedService: Neo4jSeedService; @@ -103,7 +105,7 @@ describe("UsersRepository", () => { let user: User; beforeAll(async () => { - user = await usersRepository.findUserById("3109f9e2-a262-4aef-b648-90d86d6fbf6c"); + user = await usersRepository.findUserById(userIdToFind); }); it("should return a user", async () => { diff --git a/src/users/repositories/users/users.repository.ts b/src/users/repositories/users/users.repository.ts index 1e14892..9165409 100644 --- a/src/users/repositories/users/users.repository.ts +++ b/src/users/repositories/users/users.repository.ts @@ -2,9 +2,10 @@ import { Inject, Injectable } from "@nestjs/common"; import { Role, User } from "../../models"; import { IUsersRepository } from "./users.repository.interface"; import { Neo4jService } from "../../../neo4j/services/neo4j.service"; -import { UserToSexualityRelTypes } from "../../models/toSexuality"; -import { UserToGenderRelTypes } from "../../models/toGender"; -import { UserToOpennessRelTypes } from "../../models/toOpenness"; +import { UserToSexualityRelTypes, HasSexualityProps } from "../../models/toSexuality"; +import { UserToGenderRelTypes, HasGenderProps } from "../../models/toGender"; +import { UserToOpennessRelTypes, HasOpennessProps } from "../../models/toOpenness"; +import { UserToSelfRelTypes, GotBannedProps } from "../../models/toSelf"; @Injectable() export class UsersRepository implements IUsersRepository { @@ -41,8 +42,8 @@ export class UsersRepository implements IUsersRepository { return new User(props, this._neo4jService); } - public async findUserById(userId: string): Promise { - const queryResult = await this._neo4jService.read( + public async findUserById(userId: UUID): Promise { + const queryResult = await this._neo4jService.tryReadAsync( `MATCH (u:User {userId: $userId}) RETURN u`, { userId: userId, @@ -151,6 +152,8 @@ export class UsersRepository implements IUsersRepository { u.phoneNumber = $phoneNumber, u.phoneNumberVerified = $phoneNumberVerified, u.username = $username, + u.bio = $bio, + u.avatar = $avatar, u.normalizedUsername = $normalizedUsername, u.email = $email, u.emailVerified = $emailVerified, @@ -161,9 +164,11 @@ export class UsersRepository implements IUsersRepository { `, { userId: user.userId, - phoneNumber: user.phoneNumber, + phoneNumber: user.phoneNumber ?? "", phoneNumberVerified: user.phoneNumberVerified, username: user.username, + bio: user.bio ?? "", + avatar: user.avatar ?? "", normalizedUsername: user.username.toUpperCase(), email: user.email, emailVerified: user.emailVerified, @@ -171,13 +176,187 @@ export class UsersRepository implements IUsersRepository { level: user.level, roles: user.roles, updatedAt: new Date().getTime(), - } as Omit + } as User ); } - public async deleteUser(userId: string): Promise { + public async deleteUser(userId: UUID): Promise { await this._neo4jService.tryWriteAsync(`MATCH (u:User {userId: $userId}) DETACH DELETE u`, { userId: userId, }); } + + public async connectUserWithSexuality( + userId: UUID, + sexualityId: UUID, + hasSexualityProps: HasSexualityProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId : $userId }), (s:Sexuality { sexualityId: $sexualityId }) + MERGE (u)-[:${UserToSexualityRelTypes.HAS_SEXUALITY} { + isPrivate: $isPrivate + }]->(s) + `, + { + userId, + sexualityId, + isPrivate: hasSexualityProps.isPrivate, + } + ); + } + public async detachUserWithSexuality(userId: UUID): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToSexualityRelTypes.HAS_SEXUALITY}]->(s:Sexuality) + DELETE r + `, + { + userId, + } + ); + } + public async updateRelationshipPropsOfHasSexuality( + userId: UUID, + hasSexualityProps: HasSexualityProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToSexualityRelTypes.HAS_SEXUALITY}]->(s:Sexuality) + SET r.isPrivate = $isPrivate + `, + { + userId, + isPrivate: hasSexualityProps.isPrivate, + } + ); + } + + public async banUser(userId: UUID, banProps: GotBannedProps): Promise { + await this._neo4jService.tryWriteAsync( + `MATCH (u:User {userId: $userId}) + CREATE (u)-[:${UserToSelfRelTypes.GOT_BANNED} {bannedAt: $bannedAt, moderatorId: $moderatorId, reason: $reason}]->(u) + `, + { + userId: userId, + bannedAt: banProps.bannedAt, + moderatorId: banProps.moderatorId, + reason: banProps.reason, + } + ); + } + + public async connectUserWithGender( + userId: UUID, + genderId: UUID, + hasGenderProps: HasGenderProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId : $userId }), (g:Gender { genderId: $genderId }) + MERGE (u)-[:${UserToGenderRelTypes.HAS_GENDER} { + isPrivate: $isPrivate + }]->(g) + `, + { + userId, + genderId, + isPrivate: hasGenderProps.isPrivate, + } + ); + } + public async detachUserWithGender(userId: UUID): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToGenderRelTypes.HAS_GENDER}]->(g:Gender) + DELETE r + `, + { + userId, + } + ); + } + public async updateRelationshipPropsOfHasGender( + userId: UUID, + hasGenderProps: HasGenderProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToGenderRelTypes.HAS_GENDER}]->(g:Gender) + SET r.isPrivate = $isPrivate + `, + { + userId, + isPrivate: hasGenderProps.isPrivate, + } + ); + } + public async unbanUser(userId: UUID): Promise { + await this._neo4jService.tryWriteAsync( + `MATCH (u:User {userId: $userId})-[r:${UserToSelfRelTypes.GOT_BANNED}]->(u) DELETE r`, + { + userId: userId, + } + ); + } + + public async connectUserWithOpenness( + userId: UUID, + opennessId: UUID, + hasOpennessProps: HasOpennessProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId : $userId }), (o:Openness { opennessId: $opennessId }) + MERGE (u)-[:${UserToOpennessRelTypes.HAS_OPENNESS_LEVEL_OF} { + isPrivate: $isPrivate + }]->(o) + `, + { + userId, + opennessId, + isPrivate: hasOpennessProps.isPrivate, + } + ); + } + public async detachUserWithOpenness(userId: UUID): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToOpennessRelTypes.HAS_OPENNESS_LEVEL_OF}]->(o:Openness) + DELETE r + `, + { + userId, + } + ); + } + public async updateRelationshipPropsOfHasOpenness( + userId: UUID, + hasOpennessProps: HasOpennessProps + ): Promise { + await this._neo4jService.tryWriteAsync( + ` + MATCH (u:User { userId: $userId })-[r:${UserToOpennessRelTypes.HAS_OPENNESS_LEVEL_OF}]->(o:Openness) + SET r.isPrivate = $isPrivate + `, + { + userId, + isPrivate: hasOpennessProps.isPrivate, + } + ); + } + + public async addPreviouslyBanned(userId: UUID, banProps: GotBannedProps): Promise { + await this._neo4jService.tryWriteAsync( + `MATCH (u:User {userId: $userId}) + CREATE (u)-[:${UserToSelfRelTypes.PREVIOUSLY_BANNED} {bannedAt: $bannedAt, moderatorId: $moderatorId, reason: $reason}]->(u) + `, + { + userId: userId, + bannedAt: banProps.bannedAt, + moderatorId: banProps.moderatorId, + reason: banProps.reason, + } + ); + } } diff --git a/src/users/services/.gitkeep b/src/users/services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/users/services/profileSetup/profileSetup.service.interface.ts b/src/users/services/profileSetup/profileSetup.service.interface.ts new file mode 100644 index 0000000..51cd224 --- /dev/null +++ b/src/users/services/profileSetup/profileSetup.service.interface.ts @@ -0,0 +1,5 @@ +import { SetupProfileDto } from "../../dtos"; + +export interface IProfileSetupService { + setupProfile(payload: SetupProfileDto): Promise; +} diff --git a/src/users/services/profileSetup/profileSetup.service.ts b/src/users/services/profileSetup/profileSetup.service.ts new file mode 100644 index 0000000..439d644 --- /dev/null +++ b/src/users/services/profileSetup/profileSetup.service.ts @@ -0,0 +1,132 @@ +import { IProfileSetupService } from "./profileSetup.service.interface"; +import { SetupProfileDto } from "../../dtos"; +import { HttpException, Inject, Injectable, Scope } from "@nestjs/common"; +import { Request } from "express"; +import { REQUEST } from "@nestjs/core"; +import { User } from "../../models"; +import { DatabaseContext } from "../../../database-access-layer/databaseContext"; +import { _$ } from "../../../_domain/injectableTokens"; +import { HasSexualityProps } from "../../models/toSexuality"; +import { HasGenderProps } from "../../models/toGender"; +import { HasOpennessProps } from "../../models/toOpenness"; + +@Injectable({ scope: Scope.DEFAULT }) +export class ProfileSetupService implements IProfileSetupService { + private readonly _request: Request; + private readonly _dbContext: DatabaseContext; + + constructor( + @Inject(REQUEST) request: Request, + @Inject(_$.IDatabaseContext) dbContext: DatabaseContext + ) { + this._request = request; + this._dbContext = dbContext; + } + + public async setupProfile(payload: SetupProfileDto): Promise { + const user: User = this.getUserFromRequest(); + + user.bio = payload.bio; + user.avatar = payload.avatar; + await this._dbContext.Users.updateUser(user); + + const userSexuality = await user.getSexuality(); + const sexuality = await this._dbContext.Sexualities.findSexualityById(payload.sexualityId); + if (!sexuality) throw new HttpException("Sexuality not found.", 404); + if (userSexuality === null) { + await this._dbContext.Users.connectUserWithSexuality( + user.userId, + sexuality.sexualityId, + new HasSexualityProps({ isPrivate: !payload.isSexualityOpen }) + ); + } else { + // If the sexuality is changed, update it. else, skip. + if (userSexuality.sexualityId !== payload.sexualityId) { + // Delete the relationship first. + await this._dbContext.Users.detachUserWithSexuality(user.userId); + // Connect with the new one. + await this._dbContext.Users.connectUserWithSexuality( + user.userId, + sexuality.sexualityId, + new HasSexualityProps({ isPrivate: !payload.isSexualityOpen }) + ); + } else { + // If the privacy rule of sexuality of this user has changed, just update the relationship property + if (!payload.isSexualityOpen !== user.isSexualityPrivate) { + await this._dbContext.Users.updateRelationshipPropsOfHasSexuality( + user.userId, + new HasSexualityProps({ isPrivate: !payload.isSexualityOpen }) + ); + } + } + } + + const userGender = await user.getGender(); + const gender = await this._dbContext.Genders.findGenderById(payload.genderId); + if (!gender) throw new HttpException("Gender not found.", 404); + if (userGender === null) { + await this._dbContext.Users.connectUserWithGender( + user.userId, + gender.genderId, + new HasGenderProps({ isPrivate: payload.isGenderPrivate }) + ); + } else { + // If the gender is changed, update it. else, skip. + if (userGender.genderId !== payload.genderId) { + // Delete the relationship first. + await this._dbContext.Users.detachUserWithGender(user.userId); + // Connect with the new one. + await this._dbContext.Users.connectUserWithGender( + user.userId, + gender.genderId, + new HasGenderProps({ isPrivate: payload.isGenderPrivate }) + ); + } else { + // If the privacy rule of gender of this user has changed, just update the relationship property + if (payload.isGenderPrivate !== user.isGenderPrivate) { + await this._dbContext.Users.updateRelationshipPropsOfHasGender( + user.userId, + new HasGenderProps({ isPrivate: payload.isGenderPrivate }) + ); + } + } + } + + const userOpenness = await user.getOpenness(); + const openness = await this._dbContext.Openness.findOpennessById(payload.opennessId); + if (!openness) throw new HttpException("Openness not found.", 404); + if (userOpenness === null) { + await this._dbContext.Users.connectUserWithOpenness( + user.userId, + openness.opennessId, + new HasOpennessProps({ isPrivate: payload.isGenderPrivate }) + ); + } else { + // If the openness is changed, update it. else, skip. + if (userOpenness.opennessId !== payload.opennessId) { + // Delete the relationship first. + await this._dbContext.Users.detachUserWithOpenness(user.userId); + // Connect with the new one. + await this._dbContext.Users.connectUserWithOpenness( + user.userId, + openness.opennessId, + new HasOpennessProps({ isPrivate: payload.isGenderPrivate }) + ); + } else { + // If the privacy rule of openness of this user has changed, just update the relationship property + if (payload.isOpennessPrivate !== user.isOpennessPrivate) { + await this._dbContext.Users.updateRelationshipPropsOfHasOpenness( + user.userId, + new HasOpennessProps({ isPrivate: payload.isOpennessPrivate }) + ); + } + } + } + } + + private getUserFromRequest(): User { + const user = this._request.user as User; + if (user === undefined) throw new HttpException("Authentication failed.", 403); + return user; + } +} diff --git a/src/users/services/userHistory/userHistory.service.interface.ts b/src/users/services/userHistory/userHistory.service.interface.ts new file mode 100644 index 0000000..4a5cd11 --- /dev/null +++ b/src/users/services/userHistory/userHistory.service.interface.ts @@ -0,0 +1,10 @@ +import { Comment } from "../../../comments/models"; +import { Post } from "../../../posts/models"; + +export interface IUserHistoryService { + getPostsHistoryByUsername(username: string): Promise; + + getCommentsHistoryByUsername(username: string): Promise; + + getTotalLikesByUsername(username: string): Promise; +} diff --git a/src/users/services/userHistory/userHistory.service.ts b/src/users/services/userHistory/userHistory.service.ts new file mode 100644 index 0000000..20a66c4 --- /dev/null +++ b/src/users/services/userHistory/userHistory.service.ts @@ -0,0 +1,29 @@ +import { Post } from "../../../posts/models"; +import { IUserHistoryService } from "./userHistory.service.interface"; +import { DatabaseContext } from "../../../database-access-layer/databaseContext"; +import { HttpException, Inject } from "@nestjs/common"; +import { _$ } from "../../../_domain/injectableTokens"; +import { Comment } from "../../../comments/models"; + +export class UserHistoryService implements IUserHistoryService { + private readonly _dbContext: DatabaseContext; + + constructor(@Inject(_$.IDatabaseContext) dbContext: DatabaseContext) { + this._dbContext = dbContext; + } + + public async getPostsHistoryByUsername(username: string): Promise { + const user = await this._dbContext.Users.findUserByUsername(username); + if (!user) throw new HttpException("User does not exist.", 404); + + return await this._dbContext.Posts.getPostHistoryByUserId(user.userId); + } + + public async getCommentsHistoryByUsername(username: string): Promise { + throw new Error("Not implemented"); + } + + public async getTotalLikesByUsername(username: string): Promise { + throw new Error("Not implemented"); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 2b2f137..97e99a1 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,21 +3,75 @@ import { UsersRepository } from "./repositories/users/users.repository"; import { UsersController } from "./controllers/users.controller"; import { _$ } from "../_domain/injectableTokens"; import { DatabaseAccessLayerModule } from "../database-access-layer/database-access-layer.module"; +import { ProfileSetupService } from "./services/profileSetup/profileSetup.service"; +import { GenderRepository } from "./repositories/gender/gender.repository"; +import { SexualityRepository } from "./repositories/sexuality/sexuality.repository"; +import { OpennessRepository } from "./repositories/openness/openness.repository"; +import { SexualitiesController } from "./controllers/sexualities.controller"; +import { GendersController } from "./controllers/genders.controller"; +import { OpennessController } from "./controllers/openness.controller"; +import { UserHistoryService } from "./services/userHistory/userHistory.service"; +import { ModerationModule } from "../moderation/moderation.module"; +import { GoogleCloudRecaptchaEnterpriseModule } from "../google-cloud-recaptcha-enterprise/google-cloud-recaptcha-enterprise.module"; @Module({ - imports: [forwardRef(() => DatabaseAccessLayerModule)], + imports: [ + forwardRef(() => DatabaseAccessLayerModule), + ModerationModule, + GoogleCloudRecaptchaEnterpriseModule, + ], providers: [ { provide: _$.IUsersRepository, useClass: UsersRepository, }, + { + provide: _$.IProfileSetupService, + useClass: ProfileSetupService, + }, + { + provide: _$.IUserHistoryService, + useClass: UserHistoryService, + }, + { + provide: _$.IGenderRepository, + useClass: GenderRepository, + }, + { + provide: _$.ISexualityRepository, + useClass: SexualityRepository, + }, + { + provide: _$.IOpennessRepository, + useClass: OpennessRepository, + }, ], exports: [ { provide: _$.IUsersRepository, useClass: UsersRepository, }, + { + provide: _$.IProfileSetupService, + useClass: ProfileSetupService, + }, + { + provide: _$.IUserHistoryService, + useClass: UserHistoryService, + }, + { + provide: _$.IGenderRepository, + useClass: GenderRepository, + }, + { + provide: _$.ISexualityRepository, + useClass: SexualityRepository, + }, + { + provide: _$.IOpennessRepository, + useClass: OpennessRepository, + }, ], - controllers: [UsersController], + controllers: [UsersController, SexualitiesController, GendersController, OpennessController], }) export class UsersModule {}