diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6f3a291 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index eb150f4..a41df57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,15 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "mysql2": "^3.15.3", - "prisma": "^6.18.0" + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.18.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } }, "node_modules/@prisma/client": { @@ -97,6 +103,13 @@ "@prisma/debug": "6.18.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -116,6 +129,18 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -125,6 +150,21 @@ "node": ">= 6.0.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -189,6 +229,22 @@ "node": ">=0.10.0" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -313,6 +369,12 @@ "node": ">=0.1.90" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -416,6 +478,15 @@ } } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -481,6 +552,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -665,6 +745,12 @@ "node": ">= 0.8" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -737,6 +823,27 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -820,6 +927,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -856,12 +974,109 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -943,6 +1158,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -1078,6 +1305,12 @@ "node": "^14.16.0 || >=16.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1144,6 +1377,83 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -1160,6 +1470,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -1335,6 +1650,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", @@ -1473,6 +1800,42 @@ "node": ">= 0.8" } }, + "node_modules/swagger-autogen": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", + "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.4.1", + "deepmerge": "^4.2.2", + "glob": "^7.1.7", + "json5": "^2.2.3" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tinyexec": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", @@ -1502,6 +1865,12 @@ "node": ">= 0.6" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1511,6 +1880,15 @@ "node": ">= 0.8" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 70ffcd4..afd79a6 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,15 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "mysql2": "^3.15.3", - "prisma": "^6.18.0" + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.18.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/prisma/migrations/20251127064415_add_optional_password/migration.sql b/prisma/migrations/20251127064415_add_optional_password/migration.sql new file mode 100644 index 0000000..e4426f6 --- /dev/null +++ b/prisma/migrations/20251127064415_add_optional_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `user` MODIFY `password` VARCHAR(100) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c720475..2110905 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,7 +16,7 @@ datasource db { model User { id Int @id @default(autoincrement()) email String @unique(map: "email") @db.VarChar(255) - password String @db.VarChar(100) + password String? @db.VarChar(100) name String @db.VarChar(100) gender String @db.VarChar(15) birth DateTime @db.Date diff --git a/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..3ea9fc4 --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,105 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; +import { prisma } from "./db.config.js"; +import jwt from "jsonwebtoken"; // JWT 생성을 위해 import + +dotenv.config(); +const secret = process.env.JWT_SECRET; // .env의 비밀 키 +console.log('JWT SECRET:', secret); + +export const generateAccessToken = (user) => { + return jwt.sign( + { id: user.id, email: user.email }, + secret, + { expiresIn: '1h' } + ); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign( + { id: user.id }, + secret, + { expiresIn: '14d' } + ); +}; + +// GoogleVerify +const googleVerify = async (profile) => { + const email = profile.emails?.[0]?.value; + if (!email) { + throw new Error(`profile.email was not found: ${profile}`); + } + + const user = await prisma.user.findFirst({ where: { email } }); + if (user !== null) { + return { id: user.id, email: user.email, name: user.name }; + } + + const created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: "추후 수정", + birth: new Date(1970, 0, 1), + address: "추후 수정", + detailAddress: "추후 수정", + phoneNumber: "추후 수정", + }, + }); + + return { id: created.id, email: created.email, name: created.name }; +}; + +// GoogleStrategy + +export const googleStrategy = new GoogleStrategy( + { + clientID: process.env.PASSPORT_GOOGLE_CLIENT_ID, + clientSecret: process.env.PASSPORT_GOOGLE_CLIENT_SECRET, + callbackURL: "/oauth2/callback/google", + scope: ["email", "profile"], + }, + + + async (accessToken, refreshToken, profile, cb) => { + try { + + const user = await googleVerify(profile); + + + const jwtAccessToken = generateAccessToken(user); + const jwtRefreshToken = generateRefreshToken(user); + + + + return cb(null, { + accessToken: jwtAccessToken, + refreshToken: jwtRefreshToken, + }); + + } catch (err) { + return cb(err); + } + } +); + +const jwtOptions = { + // 요청 헤더의 'Authorization'에서 'Bearer ' 토큰을 추출 + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, +}; + +export const jwtStrategy = new JwtStrategy(jwtOptions, async (payload, done) => { + try { + const user = await prisma.user.findFirst({ where: { id: payload.id } }); + + if (user) { + return done(null, user); + } else { + return done(null, false); + } + } catch (err) { + return done(err, false); + } +}); \ No newline at end of file diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index b558b6a..84d1471 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -2,6 +2,155 @@ import { StatusCodes } from "http-status-codes"; import { missionAdd } from "../services/mission.service.js"; export const addMissionController = async (req, res, next) => { + /* + #swagger.summary = '레스토랑 미션 생성 API' + #swagger.parameters['restaurant_id'] = { + in: 'path', + description: '미션을 추가할 레스토랑 ID', + required: true, + type: 'number', + example: 5 + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["title", "description", "reward"], + properties: { + title: { type: "string", example: "스시 3접시 먹기" }, + description: { type: "string", example: "스시 하루에서 3접시 먹으면 1000포인트 적립" }, + reward: { type: "number", example: 1000, description: "미션 보상 포인트" }, + } + } + } + } + } + + #swagger.responses[200] = { + description: "미션 생성 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + description: "생성된 미션 정보", + properties: { + mission_id: { type: "number", example: 20 }, + restaurant_id: { type: "number", example: 5 }, + title: { type: "string", example: "스시 3접시 먹기" }, + description: { type: "string", example: "스시 하루에서 3접시 먹으면 1000포인트 적립" }, + reward: { type: "number", example: 1000 }, + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "레스토랑 ID를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "레스토랑 미션 생성 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } +*/ try { const { restaurant_id } = req.params; const mission = await missionAdd({ diff --git a/src/controllers/restaurant.controller.js b/src/controllers/restaurant.controller.js index 611cb35..e1e7996 100644 --- a/src/controllers/restaurant.controller.js +++ b/src/controllers/restaurant.controller.js @@ -2,10 +2,177 @@ import { StatusCodes } from "http-status-codes"; import { restaurantAdd, listRestaurantReviews, missionListByRestaurant } from "../services/restaurant.service.js"; export const regionForRestaurant = async (req, res, next) => { + /* + #swagger.summary = '레스토랑 등록 API'; + #swagger.description = '레스토랑 정보를 입력받아 새로운 레스토랑을 생성합니다.'; + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["restaurant_name", "restaurant_address", "latitude", "longitude"], + properties: { + restaurant_name: { + type: "string", + example: "스시 하루", + description: "레스토랑 이름" + }, + restaurant_address: { + type: "string", + example: "서울 강남구 테헤란로 123", + description: "레스토랑 주소" + }, + latitude: { + type: "number", + example: "37.498", + description: "위도" + }, + longitude: { + type: "number", + example: "127.027", + description: "경도" + } + } + } + } + } + } + + #swagger.responses[200] = { + description: "레스토랑 등록 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "object", + properties: { + restaurant_id: { type: "number", example: 10 }, + restaurant_name: { type: "string", example: "김밥천국 강남점" }, + restaurant_address: { type: "string", example: "서울 강남구 테헤란로 123" }, + latitude: { type: "float", example: "37.498" }, + longitude: { type: "float", example: "127.027" }, + created_at: { type: "string", example: "2025-01-10T12:30:00Z" } + } + } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "연관된 리소스를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "레스토랑 목록 추가 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + +*/ + try { console.log("body:", req.body); const restaurant = await restaurantAdd(req.body); - res.status(StatusCodes.OK).success(restaurant); + res.status(StatusCodes.CREATED).success(restaurant); } catch (error) { next(error); } @@ -13,12 +180,47 @@ export const regionForRestaurant = async (req, res, next) => { //특정 레스토랑의 리뷰 목록 export const handleListRestaurantReviews = async (req, res, next) => { +/* + #swagger.summary = '레스토랑 리뷰 목록 조회 API'; + #swagger.responses[200] = { + description: "레스토랑 리뷰 목록 조회 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, + restaurant: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } }, + user: { type: "object", properties: { id: { type: "number" }, email: { type: "string" }, name: { type: "string" } } }, + content: { type: "string" } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; +*/ try { const reviews = await listRestaurantReviews( parseInt(req.params.restaurant_id), typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 ); - res.status(StatusCodes.OK).success(reviews); + res.status(StatusCodes.CREATED).success(reviews); } catch (error) { next(error); } @@ -26,6 +228,170 @@ export const handleListRestaurantReviews = async (req, res, next) => { //특정 레스토랑 미션 목록 export const getMissionsByRestaurantController = async (req, res, next) => { + /* + #swagger.summary = '특정 레스토랑 미션 목록 조회 API'; + #swagger.description = '특정 레스토랑에 등록된 미션들을 커서 기반으로 조회합니다.'; + + #swagger.parameters['restaurant_id'] = { + in: "path", + required: true, + description: "레스토랑 ID", + schema: { type: "number", example: 3 } + } + + #swagger.parameters['cursor'] = { + in: "query", + required: false, + description: "커서 값(마지막 mission_id). 없으면 0부터 시작.", + schema: { type: "number", example: 10 } + } + + #swagger.parameters['limit'] = { + in: "query", + required: false, + description: "한 페이지에 불러올 미션 수", + schema: { type: "number", example: 5 } + } + + #swagger.responses[200] = { + description: "레스토랑 미션 목록 조회 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + mission_id: { type: "number", example: 12 }, + title: { type: "string", example: "산책 인증 미션" }, + description: { type: "string", example: "3km 산책 인증하면 리워드 지급" }, + reward: { type: "number", example: 500 } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { + type: "number", + nullable: true, + example: 15, + description: "다음 페이지 요청 시 사용할 커서 값" + } + } + } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "레스토랑 ID를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "레스토랑 미션 목록 조회 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } +*/ try { const { restaurant_id } = req.params; const { cursor, limit } = req.query; @@ -36,7 +402,7 @@ export const getMissionsByRestaurantController = async (req, res, next) => { Number(limit) || 5 ); - res.status(StatusCodes.OK).success(result); + res.status(StatusCodes.CREATED).success(result); } catch (error) { next(error); } diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index 061d155..26af4c6 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -1,8 +1,163 @@ import { StatusCodes } from "http-status-codes"; -import { reviewAdd } from "../services/review.service.js"; -import { getMyReviews } from "../repositories/review.repository.js"; +import { reviewAdd, listMyReviews } from "../services/review.service.js"; export const addReviewController = async (req, res, next) => { + /* + #swagger.summary = '리뷰 등록 API' + #swagger.parameters['restaurant_id'] = { + in: 'path', + description: '리뷰를 남길 레스토랑 ID', + required: true, + type: 'number', + example: 2 + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["user_id","mission_id", "content", "rating", "photo"], + properties: { + user_id: { type: "number", example: 5, description: "리뷰 작성자 ID" }, + mission_id: { type: "number", example: 1, description: "미션 Id"}, + content: { type: "string", example: "여기 진짜 맛있어요!" }, + rating: { type: "number", example: 5, description: "별점 (1~5)" }, + photo: { type: "string", nullable: true, example: "https://cdn.example.com/photo1.jpg" } + } + } + } + } + } + + #swagger.responses[200] = { + description: "리뷰 등록 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + description: "등록된 리뷰 데이터", + properties: { + review_id: { type: "number", example: 50 }, + user_id: { type: "number", example: 1 }, + mission_id: { type: "number", example: 101, nullable: true }, + restaurant_id: { type: "number", example: 12 }, + content: { type: "string", example: "여기 너무 맛있어요!" }, + rating: { type: "number", example: 5 }, + photo: { type: "string", example: "https://cdn.example.com/photo1.jpg" }, + created_at: { type: "string", example: "2025-01-22T12:33:11.000Z" } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "인증 토큰이 없거나 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "해당 ID의 레스토랑 또는 사용자를 찾을 수 없습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "리뷰 등록 중 예기치 않은 서버 오류가 발생했습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + +*/ + try { const { restaurant_id } = req.params; const { user_id, mission_id } = req.body; @@ -10,10 +165,11 @@ export const addReviewController = async (req, res, next) => { const review = await reviewAdd( Number(user_id), Number(mission_id), + Number(restaurant_id), req.body // content, rating, photo 등이 담긴 객체 ); - res.status(StatusCodes.OK).success(review); + res.status(StatusCodes.CREATED).success(review); } catch (error) { next(error); } @@ -21,36 +177,198 @@ export const addReviewController = async (req, res, next) => { // '내가 쓴 리뷰' 목록을 조회하는 전용 컨트롤러 export const handleUserReviewList = async (req, res, next) => { -  try { -    const userId = parseInt(req.params.user_id); -    const { cursor, limit = 5 } = req.query; + /* + #swagger.summary = '내가 쓴 리뷰 목록 조회 API'; + #swagger.parameters['user_id'] = { + in: 'path', + description: '사용자 ID', + required: true, + type: 'number', + example: 12 + } + + #swagger.parameters['cursor'] = { + in: 'query', + description: '페이지네이션용 커서', + required: false, + type: 'number', + example: 10 + } + + #swagger.parameters['limit'] = { + in: 'query', + description: '한 번에 가져올 리뷰 개수', + required: false, + type: 'number', + example: 5 + } + + #swagger.responses[200] = { +    description: "내가 쓴 리뷰 목록 조회 성공 응답", +    content: { +      "application/json": { +        schema: { +          type: "object", +          properties: { +            resultType: { type: "string", example: "SUCCESS" }, +            error: { type: "object", nullable: true, example: null }, +            success: { +              type: "object", +              description: "내가 쓴 리뷰 데이터", +              properties: { +                data: { +                  type: "array", +                  items: { +                    type: "object", +                    properties: { +                      review_id: { type: "number", example: 1 }, +                      restaurant_name: { type: "string", example: "김밥천국 홍대점" }, +                      rating: { type: "number", example: 4 }, +                      content: { type: "string", example: "여기 너무 맛있어요!" }, +                      created_at: { type: "string", example: "2025-01-22T12:33:11.000Z" } +                    } +                  } +                }, +                nextCursor: { +                  type: "number", +                  nullable: true, +                  example: 20, +                  description: "다음 페이지 호출을 위한 커서 (없으면 null)" +                } +              } +            } +          } +        } +      } +    } +  } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "해당 user_id를 가진 사용자를 찾을 수 없습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "리뷰 목록 조회 중 예기치 않은 서버 오류가 발생했습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + +*/ + + try { + const userId = parseInt(req.params.user_id); + const { cursor, limit = 5 } = req.query; + + const cursorValue = req.query.cursor ? parseInt(req.query.cursor, 10) : 0; + const limitValue = req.query.limit ? parseInt(req.query.limit, 10) : 5; if (isNaN(userId)) { - return res.status(StatusCodes.BAD_REQUEST).error({ - errorCode: "U004", - reason: "유효하지 않은 사용자 ID 형식입니다." - }); + throw new Error('유효하지 않은 사용자 ID 형식입니다.'); } -    const { reviews, nextCursor } = await getMyReviews( -    userId, -      cursor, -      Number(limit) -    ); - -    return res.status(200).json({ -      success: true, -      message: "내가 쓴 리뷰 목록 조회 성공", -      data: reviews.map(r => ({ -        review_id: r.review_id, - restaurant_name: r.restaurant.restaurant_name, // 가게 이름 포함 -        rating: r.rating, -        content: r.content, -        created_at: r.created_at, -      })), -      nextCursor, -    }); -  } catch (error) { -    next(error); -  } + const { reviews, nextCursor } = await listMyReviews( // 👈 서비스 함수 사용 + userId, + cursorValue, + limitValue + );     + + res.status(StatusCodes.OK).success({ + data: reviews.map(r => ({ + review_id: r.review_id, + restaurant_name: r.restaurant.restaurant_name, + rating: r.rating, + content: r.content, + created_at: r.created_at, + })), + nextCursor, + }); + } catch (error) { + next(error); + } }; \ No newline at end of file diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 687fa0a..23c75d9 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,17 +1,344 @@ import { StatusCodes } from "http-status-codes"; import { bodyToUser } from "../dtos/user.dto.js"; -import { userSignUp } from "../services/user.service.js"; +import { userSignUp, userUpdateInfo } from "../services/user.service.js"; export const handleUserSignUp = async (req, res, next) => { + /* + #swagger.summary = '회원 가입 API'; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + gender: { type: "string" }, + birth: { type: "string", format: "date" }, + address: { type: "string" }, + detailAddress: { type: "string" }, + phoneNumber: { type: "string" }, + preferences: { type: "array", items: { type: "number" } } + } + } + } + } + }; + #swagger.responses[200] = { + description: "회원 가입 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + email: { type: "string" }, + name: { type: "string" }, + preferCategory: { type: "array", items: { type: "string" } } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "리소스를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "회원가입 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + */ console.log("회원가입을 요청했습니다!"); console.log("body:", req.body); // 값이 잘 들어오나 확인하기 위한 테스트용 + try { - const user = await userSignUp(bodyToUser(req.body)); - - res.status(StatusCodes.OK).success(user); + const user = await userSignUp(bodyToUser(req.body)); + + res.status(StatusCodes.CREATED).success(user); } catch (error) { next(error); } +}; + +export const handleUserUpdateInfo = async (req, res, next) => { + /* + #swagger.summary = '사용자 정보 수정 API' + #swagger.description = '인증된 사용자(토큰 기반)의 정보를 수정합니다. (닉네임, 프로필 사진 등)' + + #swagger.security = [{ + "bearerAuth": [] + }] + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", example: "새닉네임", description: "변경할 닉네임" }, + gender: { type: "string", example: "남자", description: "변경할 성별" }, + birth: { type: "string", example: "2004-04-21", description: "변경할 생년월일" }, + address: { type: "string", example: "서울특별시 노원구", description: "변경할 주소" }, + detailAddress: { type: "string", example: "화랑로 621", description: "변경할 세부주소" }, + phoneNumber: { type: "string", example: "010-1234-1234", description: "변경할 전화번호" }, + preferences: { + type: "array", + items: { type: "number" }, + description: "사용자 선호 음식 카테고리" + example: [1, 2, 5] + } + } + }, + example: { + name: "새닉네임", + gender: "남자", + birth: "2004-04-21", + address: "서울특별시 노원구", + detailAddress: "화랑로 621", + phoneNumber: "010-1234-1234", + preferences: [1, 2, 5] + } + } + } + } + + // --- 성공 응답 --- + #swagger.responses[200] = { + description: "사용자 정보 수정 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + description: "수정된 사용자 데이터", + properties: { + id: { type: "number", example: 12 }, + email: { type: "string", example: "user@example.com" }, + name: { type: "string", example: "새닉네임" }, + gender: { type: "string", example: "남자" }, + birth: { type: "string", example: "2004-04-21" }, + address: { type: "string", example: "서울특별시 노원구" }, + detailAddress: { type: "string", example: "화랑로 621" }, + phoneNumber: { type: "string", example: "010-1234-1234" }, + preferences: { type: "array", itmes: { type: "number"}, example: [1, 2, 5]}, + updated_at: { type: "string", example: "2025-01-23T10:00:00.000Z" } + } + } + } + } + } + } + } + + // --- 에러 응답 --- + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "사용자 리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "토큰에 해당하는 사용자 정보를 찾을 수 없습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "사용자 정보 수정 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } +*/ +  const userId = req.user.id; +  +  const updateData = req.body; + +  try { +    const updatedUser = await userUpdateInfo(userId, updateData); + +    res.status(StatusCodes.OK).success(updatedUser); +  } catch (error) { +    next(error); +  } }; \ No newline at end of file diff --git a/src/controllers/user_mission.controller.js b/src/controllers/user_mission.controller.js index 58e0932..3cee0fa 100644 --- a/src/controllers/user_mission.controller.js +++ b/src/controllers/user_mission.controller.js @@ -2,6 +2,158 @@ import { StatusCodes } from "http-status-codes"; import { startMission, getOngoingMissionsService } from "../services/user_mission.service.js"; export const startMissionController = async (req, res, next) => { + /* + #swagger.summary = '미션 시작 API' + #swagger.parameters['mission_id'] = { + in: 'path', + description: '시작할 미션 ID', + required: true, + type: 'number', + example: 1 + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["user_id"], + properties: { + user_id: { + type: "number", + example: 1, + description: "미션을 시작하는 사용자 ID" + } + } + } + } + } + } + + #swagger.responses[200] = { + description: "미션 시작 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + description: "사용자가 시작한 미션 정보", + properties: { + user_mission_id: { type: "number", example: 1 }, + user_id: { type: "number", example: 5 }, + mission_id: { type: "number", example: 101 }, + status: { type: "string", example: "ONGOING", description: "미션 상태" }, + started_at: { type: "string", example: "2025-01-22T12:33:11.000Z" } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "미션 ID 혹은 사용자 ID를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "사용자 미션 시작 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } +*/ + try { const { mission_id } = req.params; // URL에서 missionId 획득 const { user_id } = req.body; @@ -10,7 +162,7 @@ export const startMissionController = async (req, res, next) => { const userMission = await startMission(user_id, missionIdAsNumber); - res.status(StatusCodes.OK).success(userMission); + res.status(StatusCodes.CREATED).success(userMission); } catch (error) { next(error); // 에러 핸들러로 넘김 } @@ -18,6 +170,182 @@ export const startMissionController = async (req, res, next) => { //진행 중인 미션 조회 export const handleOngoingMissions = async (req, res, next) => { + /* + #swagger.summary = '진행 중인 미션 목록 조회 API' + + #swagger.parameters['user_id'] = { + in: 'path', + description: '사용자 ID', + required: true, + type: 'number', + example: 5 + } + + #swagger.parameters['cursor'] = { + in: 'query', + description: '페이지네이션을 위한 커서 값', + required: false, + type: 'number', + example: 10 + } + + #swagger.parameters['limit'] = { + in: 'query', + description: '가져올 항목 개수 (기본값 5)', + required: false, + type: 'number', + example: 5 + } + + #swagger.responses[200] = { + description: "진행 중인 미션 목록 조회 성공 응답", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + missions: { + type: "array", + description: "진행 중인 미션 목록", + items: { + type: "object", + properties: { + mission_id: { type: "number", example: 101 }, + title: { type: "string", example: "오늘의 파스타 주문" }, + description: { type: "string", example: "메뉴 주문 후 인증샷 업로드" }, + reward: { type: "number", example: 300 }, + restaurant: { + type: "object", + properties: { + restaurant_id: { type: "number", example: 12 }, + restaurant_name: { type: "string", example: "김밥천국 홍대점" } + } + }, + status: { type: "string", example: "ONGOING" }, + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { + type: "number", + nullable: true, + example: 20, + description: "다음 페이지 조회를 위한 커서 값" + } + } + } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "잘못된 요청 데이터" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INVALID_INPUT" }, + reason: { type: "string", example: "요청 본문이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[401] = { + description: "인증 실패" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "UNAUTHORIZED" }, + reason: { type: "string", example: "접근 권한이 없거나 토큰이 유효하지 않습니다." } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "리소스를 찾을 수 없음" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "NOT_FOUND" }, + reason: { type: "string", example: "사용자 ID를 찾을 수 없습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } + + #swagger.responses[500] = { + description: "서버 내부 오류" + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "INTERNAL_SERVER_ERROR" }, + reason: { type: "string", example: "진행 중인 미션 조회 중에 예기치 않은 오류가 발생했습니다. " } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + } + } +*/ + try { const { user_id } = req.params; const { cursor, limit } = req.query; @@ -30,7 +358,7 @@ export const handleOngoingMissions = async (req, res, next) => { Number(limit) || 5 ); - res.status(StatusCodes.OK).success(result); + res.status(StatusCodes.CREATED).success(result); } catch (error) { next(error); } diff --git a/src/errors.js b/src/errors.js index 2a2f89a..ba84127 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,5 +1,50 @@ export class DuplicateUserEmailError extends Error { errorCode = "U001"; + statusCode = 409; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +export class InvalidInputError extends Error { + errorCode = "INVALID_INPUT"; + statusCode = 400; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +export class UnauthorizedError extends Error { + errorCode = "UNAUTHORIZED"; + statusCode = 401; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +export class ResourceNotFoundError extends Error { + errorCode = "NOT_FOUND"; + statusCode = 404; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +export class InternalServerError extends Error { + errorCode = "INTERNAL_SERVER_ERROR"; + statusCode = 500; constructor(reason, data) { super(reason); diff --git a/src/index.js b/src/index.js index 3aaab61..582d56d 100644 --- a/src/index.js +++ b/src/index.js @@ -3,24 +3,35 @@ import dotenv from "dotenv"; import express from "express"; import cookieParser from 'cookie-parser'; import morgan from "morgan"; +import swaggerAutogen from "swagger-autogen"; +import swaggerUiExpress from "swagger-ui-express"; +import passport from "passport"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import { prisma } from "./db.config.js"; import { addMissionController } from "./controllers/mission.controller.js"; -import { handleUserSignUp } from "./controllers/user.controller.js"; +import { handleUserSignUp, handleUserUpdateInfo } from "./controllers/user.controller.js"; import { regionForRestaurant, handleListRestaurantReviews, getMissionsByRestaurantController } from "./controllers/restaurant.controller.js"; import { addReviewController, handleUserReviewList } from "./controllers/review.controller.js"; import { startMissionController, handleOngoingMissions } from "./controllers/user_mission.controller.js"; dotenv.config(); +passport.use(googleStrategy); +passport.use(jwtStrategy); + const app = express(); const port = process.env.PORT; -app.use(cors()); // cors 방식 허용 app.use(morgan('dev')); +app.use(cors()); // cors 방식 허용 app.use(cookieParser()); -app.use(express.static("public")); // 정적 파일 접근 app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 +app.use(express.static("public")); // 정적 파일 접근 + +app.use(passport.initialize()); + /** * 공통 응답을 사용할 수 있는 헬퍼 함수 등록 */ @@ -40,6 +51,75 @@ app.use((req, res, next) => { next(); }); +app.use( + "/docs", + swaggerUiExpress.serve, + swaggerUiExpress.setup({}, { + swaggerOptions: { + url: "/openapi.json", + }, + }) +); + +app.get("/oauth2/login/google", + passport.authenticate("google", { + session: false + }) +); +app.get( + "/oauth2/callback/google", + passport.authenticate("google", { + session: false, + failureRedirect: "/login-failed", + }), + (req, res) => { + const tokens = req.user; + + res.status(200).json({ + resultType: "SUCCESS", + error: null, + success: { + message: "Google 로그인 성공!", + tokens: tokens, // { "accessToken": "...", "refreshToken": "..." } + } + }); + } +); + +app.get("/openapi.json", async (req, res, next) => { + // #swagger.ignore = true + const options = { + openapi: "3.0.0", + disableLogs: true, + writeOutputFile: false, + }; + const outputFile = "/dev/null"; // 파일 출력은 사용하지 않습니다. + const routes = ["./src/index.js"]; + const doc = { + info: { + title: "UMC 9th", + description: "UMC 9th Node.js 테스트 프로젝트입니다.", + }, + ignore: [ + 'description', + 'content', + 'in', + 'required', + 'error', + 'success', + 'title', + 'reward', + 'restaurant', + 'restaurant_name', + 'started_at', + ], + host: "localhost:3000", + }; + + const result = await swaggerAutogen(options)(outputFile, routes, doc); + res.json(result ? result.data : null); +}); + app.get("/", (req, res) => { res.send("Hello World!"); }); @@ -64,19 +144,32 @@ app.get('/getcookie', (req, res) => { } }); +const isLogin = passport.authenticate('jwt', { session: false }); + + app.post("/api/users/signup", handleUserSignUp); app.post("/api/restaurants", regionForRestaurant); app.post("/api/restaurants/:restaurant_id/missions", addMissionController); -app.post("/api/restaurants/:restaurant_id/reviews", addReviewController); +app.post("/api/restaurants/:restaurant_id/reviews", isLogin, addReviewController); app.post( - "/api/missions/:mission_id/start", + "/api/missions/:mission_id/start", isLogin, startMissionController ); + +app.get('/mypage', isLogin, (req, res) => { + res.status(200).success({ + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + user: req.user, + }); +}); + app.get("/api/restaurants/:restaurant_id/reviews", handleListRestaurantReviews); -app.get("/api/users/:user_id/reviews", handleUserReviewList); +app.get("/api/users/:user_id/reviews", isLogin, handleUserReviewList); app.get("/api/restaurants/:restaurant_id/missions", getMissionsByRestaurantController); -app.get("/api/users/:user_id/ongoing-missions", handleOngoingMissions); +app.get("/api/users/:user_id/ongoing-missions", isLogin, handleOngoingMissions); +app.patch("/api/users/me", isLogin, handleUserUpdateInfo); + /** * 전역 오류를 처리하기 위한 미들웨어 diff --git a/src/repositories/mission.repository.js b/src/repositories/mission.repository.js index 7885036..7658bdb 100644 --- a/src/repositories/mission.repository.js +++ b/src/repositories/mission.repository.js @@ -13,7 +13,6 @@ export const addMission = async (data) => { return mission.mission_id } catch (err) { console.error("미션 추가 중 에러:", err); - throw err; } }; @@ -28,6 +27,5 @@ export const getMissionById = async (mission_id) => { return mission || null; } catch (err) { console.error("ID로 특정 미션 조회 중 에러: ", err); - throw err; } } diff --git a/src/repositories/restaurant.repository.js b/src/repositories/restaurant.repository.js index 63b0af6..7bfebe7 100644 --- a/src/repositories/restaurant.repository.js +++ b/src/repositories/restaurant.repository.js @@ -14,7 +14,6 @@ export const addRestaurant = async ({ restaurant_name, restaurant_address, latit return restaurant; } catch (err) { console.error("레스토랑 추가 중 에러: ", err); - throw err; } }; @@ -27,7 +26,6 @@ export const getRestaurantById = async (id) => { return restaurant } catch (err) { console.error("특정 레스토랑 조회 중 에러: ", err); - throw err; } }; @@ -72,6 +70,5 @@ export const getMissionsByRestaurantId = async (restaurant_id, cursor = null, li return { missions, nextCursor }; } catch (err) { console.error("레스토랑 ID로 미션 조회 중 에러:", err); - throw err; } }; diff --git a/src/repositories/review.repository.js b/src/repositories/review.repository.js index 1151548..6437137 100644 --- a/src/repositories/review.repository.js +++ b/src/repositories/review.repository.js @@ -29,7 +29,6 @@ export const findReviewByMission = async (user_id, mission_id) => { return review || null; } catch (err) { console.error("해당 미션 작성 유효성 검사 중 오류: ", err); - throw err; } }; @@ -49,7 +48,6 @@ export const createReview = async (data) => { return review; } catch (err) { console.error("리뷰 추가하던 중 오류: ", err); - throw err; } }; @@ -64,7 +62,6 @@ export const getReviewById = async (review_id) => { return review || null; } catch (err) { console.error("리뷰 Id로 리뷰 조회 중 오류: ", err); - throw err; } }; @@ -90,15 +87,14 @@ export const getMyReviews = async (user_id, cursor = null, limit = 5) => { }, }, }, - orderBy: {review_id: "asc"}, + orderBy: {review_id: "desc"}, take: limit, ...(cursor && { cursor: { review_id: Number(cursor)}, skip: 1}), }); const nextCursor = reviews.length > 0 ? reviews[reviews.length - 1].review_id : null; - return { restaurantName, reviews, nextCursor }; + return { reviews, nextCursor }; } catch (err) { console.error("getRestaurantReviews Error:", err); - throw err; } }; diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js index f790a6d..5471af2 100644 --- a/src/repositories/user.repository.js +++ b/src/repositories/user.repository.js @@ -39,14 +39,17 @@ export const getUserPreferencesByUserId = async (userId) => { }, where: { userId: userId }, orderBy: { - foodCategory: "asc" - }, + foodCategory: { + name: "asc" // foodCategory 관계를 통해 name 필드를 기준으로 오름차순 정렬 + } + } }); return preferences; }; export const responseFromUser = (user, preferences) => ({ id:user.id, + token: token, email: user.email, name: user.name, address: user.address, @@ -57,3 +60,47 @@ export const responseFromUser = (user, preferences) => ({ name: pref.name })) }); + +export const updateUser = async (userId, updateData) => { + try { + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + email: true, + name: true, + gender: true, + birth: true, + address: true, + detailAddress: true, + phoneNumber: true, + }, + }); + return updatedUser; + } catch (error) { + return null; + } +}; + +export const updateUserPreferences = async (userId, foodCategoryIds) => { + await prisma.$transaction(async (tx) => { + // 기존 선호 카테고리 전체 삭제 + await tx.userFavorCategory.deleteMany({ + where: { userId: userId }, + }); + + // 새로운 카테고리 레코드 준비 및 생성 + if (foodCategoryIds && foodCategoryIds.length > 0) { + const preferenceRecords = foodCategoryIds.map(foodCategoryId => ({ + userId: userId, + foodCategoryId: foodCategoryId + })); + + await tx.userFavorCategory.createMany({ + data: preferenceRecords, + skipDuplicates: true, + }); + } + }); +}; diff --git a/src/repositories/user_mission.repository.js b/src/repositories/user_mission.repository.js index e260567..4ac9c4f 100644 --- a/src/repositories/user_mission.repository.js +++ b/src/repositories/user_mission.repository.js @@ -12,7 +12,6 @@ export const findUserByMission = async (user_id, mission_id) => { return user_mission; } catch (error) { console.error("도전 확인 중 에러:", error); - throw error; } }; @@ -31,7 +30,6 @@ export const createUserMission = async (data) => { return user_mission.user_mission_id; // PK 반환 } catch (error) { console.error("도전 미션 생성 중 에러: ", error); - throw error; } }; @@ -44,7 +42,6 @@ export const getUserMissionById = async (user_mission_id) => { return user_mission; } catch (error) { console.error("유저 미션 조회 중 에러:", error); - throw error; } }; diff --git a/src/services/mission.service.js b/src/services/mission.service.js index 08762a6..1a5e85d 100644 --- a/src/services/mission.service.js +++ b/src/services/mission.service.js @@ -1,16 +1,17 @@ import { responseFromMission } from "../dtos/mission.dto.js"; import { addMission, getMissionById } from "../repositories/mission.repository.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { getRestaurantById } from "../repositories/restaurant.repository.js"; +import { ResourceNotFoundError, InvalidInputError } from "../errors.js"; export const missionAdd = async (data) => { const restaurantId = data.restaurant_id; const existingRestaurant = await getRestaurantById(restaurantId); if(!existingRestaurant) { - throw new DuplicateUserEmailError ("해당하는 레스토랑이 존재하지 않습니다.", data); + throw new ResourceNotFoundError ("해당하는 레스토랑이 존재하지 않습니다.", data); } if(!data.title) { - throw new DuplicateUserEmailError("미션 제목을 입력해주세요.", data); + throw new InvalidInputError("미션 제목을 입력해주세요.", data); } const mission_id = await addMission(data); const newMission = await getMissionById(mission_id); diff --git a/src/services/restaurant.service.js b/src/services/restaurant.service.js index 6e5c387..baa80af 100644 --- a/src/services/restaurant.service.js +++ b/src/services/restaurant.service.js @@ -1,27 +1,25 @@ import { addRestaurant, getRestaurantById, getAllRestaurantReviews, getMissionsByRestaurantId } from "../repositories/restaurant.repository.js"; import { bodyToRestaurant, responseFromRestaurant } from "../dtos/restaurant.dto.js"; import { responseFromMission } from "../dtos/mission.dto.js"; -import { responseFromReview } from "../dtos/review.dto.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { InvalidInputError } from "../errors.js"; export const restaurantAdd = async (body) => { - const restaurantData = bodyToRestaurant(body); // 여기서 변환 + const restaurantData = bodyToRestaurant(body); if (!restaurantData.restaurant_name) { - throw new DuplicateUserEmailError("restaurant_name은 필수입니다.", body); + throw new InvalidInputError("restaurant_name은 필수입니다.", body); } - const newRestaurant = await addRestaurant(restaurantData); - - return responseFromRestaurant(restaurant); + return responseFromRestaurant(newRestaurant); }; + //특정 레스토랑의 리뷰 조회 export const listRestaurantReviews = async (restaurant_id, cursor) => { const { reviews, nextCursor } = await getAllRestaurantReviews(restaurant_id, cursor); + const missionsDto = result.missions.map((m) => responseFromMission(m)); return { - reviews: reviews.map(responseFromReview), - nextCursor: nextCursor - }; + missions: missionsDto, + }; }; //특정 레스토링 미션 조회 diff --git a/src/services/review.service.js b/src/services/review.service.js index 84b5149..2e802e6 100644 --- a/src/services/review.service.js +++ b/src/services/review.service.js @@ -7,13 +7,13 @@ import { } from "../repositories/review.repository.js"; import { responseFromReview } from "../dtos/review.dto.js"; import { getRestaurantById } from "../repositories/restaurant.repository.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { DuplicateUserEmailError, InvalidInputError, ResourceNotFoundError } from "../errors.js"; export const reviewAdd = async (user_id, mission_id, body) => { //이 유저가 이 미션을 '완료'했는지 확인 (status = 1) const completedMission = await findCompletedMission(user_id, mission_id); if (!completedMission) { - throw new DuplicateUserEmailError("미션을 완료한 사용자만 리뷰를 작성할 수 있습니다."); + throw new InvalidInputError("미션을 완료한 사용자만 리뷰를 작성할 수 있습니다."); } const restaurantIdFromMission = completedMission.mission.restaurant_id; @@ -21,13 +21,13 @@ export const reviewAdd = async (user_id, mission_id, body) => { //가게가 존재하는지 확인 const restaurant = await getRestaurantById(restaurantIdFromMission); if(!restaurant) { - throw new DuplicateUserEmailError ("리뷰를 작성하려는 레스토랑이 존재하지 않습니다.") + throw new ResourceNotFoundError ("리뷰를 작성하려는 레스토랑이 존재하지 않습니다.") } //이 미션에 대해 이미 리뷰를 작성했는지 확인 (중복 방지) const existingReview = await findReviewByMission(user_id, mission_id); if (existingReview) { - throw new DuplicateUserEmailError("이미 이 미션에 대한 리뷰를 작성했습니다."); + throw new InvalidInputError("이미 이 미션에 대한 리뷰를 작성했습니다."); } //리뷰 데이터 diff --git a/src/services/user.service.js b/src/services/user.service.js index af635be..096e6b2 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -1,14 +1,18 @@ import { responseFromUser } from "../dtos/user.dto.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { DuplicateUserEmailError, UnauthorizedError } from "../errors.js"; +import jwt from "jsonwebtoken"; import bcrypt from 'bcrypt'; import { addUser, getUser, + updateUser, + updateUserPreferences, getUserPreferencesByUserId, setPreference, } from "../repositories/user.repository.js"; +const JWT_SECRET = process.env.JWT_SECRET; export const userSignUp = async (data) => { const hashedPassword = await bcrypt.hash(data.password, 10); // 10은 salt rounds @@ -36,5 +40,28 @@ export const userSignUp = async (data) => { const user = await getUser(joinUserId); const preferences = await getUserPreferencesByUserId(joinUserId); - return responseFromUser({ user, preferences }); + const accessToken = jwt.sign( + { id: user.id, email: user.email, gender: user.gender, birth: user.birth, address: user.address }, // Payload: 사용자 ID와 이메일 + JWT_SECRET, + { expiresIn: '1h' } // 만료 시간 1시간 설정 + ); + + return responseFromUser({ user, preferences, token: accessToken }); +}; + +export const userUpdateInfo = async (userId, data) => { + const { preferences, ...updateData } = data; + const updatedUser = await updateUser(userId, updateData); + + if (!updatedUser) { + throw new UnauthorizedError("사용자를 찾을 수 없거나 업데이트할 데이터가 유효하지 않습니다."); + } + + //선호 카테고리 갱신하기 + if (preferences && preferences.length > 0) { + await updateUserPreferences(userId, preferences) + } + + const finalPreferences = await getUserPreferencesByUserId(userId); + return responseFromUser({ user: updatedUser, preferences: finalPreferences }); }; \ No newline at end of file diff --git a/src/services/user_mission.service.js b/src/services/user_mission.service.js index 0a5c52f..e61939b 100644 --- a/src/services/user_mission.service.js +++ b/src/services/user_mission.service.js @@ -7,13 +7,13 @@ import { import { getMissionById, } from "../repositories/mission.repository.js"; import { responseFromUserMission } from "../dtos/user_mission.dto.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { ResourceNotFoundError, InvalidInputError } from "../errors.js"; export const startMission = async (user_id, mission_id) => { //미션이 존재하는지 확인 const mission = await getMissionById(mission_id); if(!mission) { - throw new DuplicateUserEmailError ("해당 미션이 존재하지 않습니다.") + throw new ResourceNotFoundError ("해당 미션이 존재하지 않습니다.") } const existingChallenge = await findUserByMission( @@ -21,7 +21,7 @@ export const startMission = async (user_id, mission_id) => { mission_id ); if (existingChallenge) { - throw new DuplicateUserEmailError ("이미 도전 중이거나 완료한 미션입니다."); + throw new InvalidInputError ("이미 도전 중이거나 완료한 미션입니다."); } const challengeDate = { diff --git a/test.html b/test.html new file mode 100644 index 0000000..688a4eb --- /dev/null +++ b/test.html @@ -0,0 +1,78 @@ + + + + + 내 API 테스트하기 + + +

회원가입 테스트

+ + + + + + \ No newline at end of file