diff --git a/package-lock.json b/package-lock.json index 6bed072..94e3d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,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.19.0" + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.19.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } }, "node_modules/@prisma/client": { @@ -99,6 +105,13 @@ "@prisma/debug": "6.19.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", @@ -118,6 +131,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", @@ -127,6 +152,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", @@ -177,6 +217,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", @@ -279,6 +335,12 @@ "consola": "^3.2.3" } }, + "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", @@ -382,6 +444,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", @@ -447,6 +518,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", @@ -631,6 +711,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", @@ -703,6 +789,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", @@ -786,6 +893,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", @@ -822,6 +940,103 @@ "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.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", @@ -903,6 +1118,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", @@ -1018,6 +1245,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", @@ -1084,6 +1317,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", @@ -1100,6 +1410,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", @@ -1275,6 +1590,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", @@ -1413,6 +1740,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.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -1445,6 +1808,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", @@ -1454,6 +1823,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 49c664b..7d91d43 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,14 @@ "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.19.0" + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "prisma": "^6.19.0", + "swagger-autogen": "^2.23.7", + "swagger-ui-express": "^5.0.1" } } diff --git a/prisma/migrations/20251203163625_make_fields_optional/migration.sql b/prisma/migrations/20251203163625_make_fields_optional/migration.sql new file mode 100644 index 0000000..6ae8c7d --- /dev/null +++ b/prisma/migrations/20251203163625_make_fields_optional/migration.sql @@ -0,0 +1,115 @@ +-- CreateTable +CREATE TABLE `user` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `gender` VARCHAR(15) NULL, + `birth` DATE NULL, + `address` VARCHAR(255) NULL, + `detail_address` VARCHAR(255) NULL, + `phone_number` VARCHAR(15) NULL, + `point` INTEGER NOT NULL DEFAULT 0, + + UNIQUE INDEX `email`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `restaurant` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `address` VARCHAR(255) NOT NULL, + `detail_address` VARCHAR(255) NULL, + `phone_number` VARCHAR(15) NOT NULL, + `region_id` INTEGER NOT NULL, + `food_category_id` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `region` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `review` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `content` VARCHAR(100) NOT NULL, + `rating` FLOAT NOT NULL, + `user_id` INTEGER NOT NULL, + `restaurant_id` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `food_category` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_favor_category` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `food_category_id` INTEGER NOT NULL, + + INDEX `f_category_id`(`food_category_id`), + INDEX `user_id`(`user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `mission` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `point` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `deadline` DATE NULL, + `restaurant_id` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `user_mission` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `status` VARCHAR(20) NOT NULL DEFAULT '진행중', + `user_id` INTEGER NOT NULL, + `mission_id` INTEGER NOT NULL, + + UNIQUE INDEX `user_mission_user_id_mission_id_key`(`user_id`, `mission_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `restaurant` ADD CONSTRAINT `restaurant_region_id_fkey` FOREIGN KEY (`region_id`) REFERENCES `region`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `restaurant` ADD CONSTRAINT `restaurant_food_category_id_fkey` FOREIGN KEY (`food_category_id`) REFERENCES `food_category`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `review` ADD CONSTRAINT `review_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `review` ADD CONSTRAINT `review_restaurant_id_fkey` FOREIGN KEY (`restaurant_id`) REFERENCES `restaurant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_favor_category` ADD CONSTRAINT `user_favor_category_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_favor_category` ADD CONSTRAINT `user_favor_category_food_category_id_fkey` FOREIGN KEY (`food_category_id`) REFERENCES `food_category`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `mission` ADD CONSTRAINT `mission_restaurant_id_fkey` FOREIGN KEY (`restaurant_id`) REFERENCES `restaurant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_mission` ADD CONSTRAINT `user_mission_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_mission` ADD CONSTRAINT `user_mission_mission_id_fkey` FOREIGN KEY (`mission_id`) REFERENCES `mission`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..592fc0b --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "mysql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3e13c3..ef54c4e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,11 +17,11 @@ model User { id Int @id @default(autoincrement()) email String @unique(map: "email") @db.VarChar(255) name String @db.VarChar(100) - gender String @db.VarChar(15) - birth DateTime @db.Date - address String @db.VarChar(255) + gender String? @db.VarChar(15) + birth DateTime? @db.Date + address String? @db.VarChar(255) detailAddress String? @map("detail_address") @db.VarChar(255) - phoneNumber String @map("phone_number") @db.VarChar(15) + phoneNumber String? @map("phone_number") @db.VarChar(15) point Int @default(0) userFavorCategories UserFavorCategory[] diff --git a/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..874c61a --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,98 @@ +import dotenv from 'dotenv'; +import {Strategy as GoogleStrategy} from 'passport-google-oauth20'; +import {prisma} from "./db.config.js"; +import jwt from 'jsonwebtoken'; //json 생성을 위해 import +import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; + +dotenv.config(); +const secret = process.env.JWT_SECRET; // JWT 비밀 키 + +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, email: user.email}, + secret, + {expiresIn: '14d'} // 리프레시 토큰 유효 기간 설정 + ); +}; + +// Google Verify +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) { + return {id: user.id, email: user.email, name: user.name}; + } + + const created = await prisma.user.create({ + data: { + email, + name: profile.displayName, + gender: null, + birth: null, + address: null, + detailAddress: null, + phoneNumber: null, + }, + }); + + return {id: created.id, email: created.email, name: created.name}; +}; + + +// Google Strategy +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, cd) => { + try { + const user = await googleVerify(profile); + + const jwtAccessToken = generateAccessToken(user); + const jwtRefreshToken = generateRefreshToken(user); + + return cd(null, { + accessToken: jwtAccessToken, + refreshToken: jwtRefreshToken, + }); + } catch (err) { + return cd(err); + } + } + +) + +const jwtOptions = { + // 요청 헤더의 'Autiorization'에서 '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 76dc4f7..6d89aca 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -1,31 +1,137 @@ import { StatusCodes } from "http-status-codes"; import { bodyToMission, - bodyToChallenge, + bodyToChallenge, + paramsToCompleteMission } from "../dtos/mission.dto.js"; import { createMission, challengeMission, + completeMission, } from "../services/mission.service.js"; export const handleAddMission = async (req, res, next) => { + /* + #swagger.summary = '미션 추가 API'; + #swagger.parameters['restaurantId'] = { description: '가게 ID', type: 'number' }; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + point: { type: "number", example: 500 }, + content: { type: "string", example: "1만원 이상 구매" }, + deadline: { type: "string", format: "date", example: "2025-12-31" } + } + } + } + } + }; + #swagger.responses[201] = { + description: "미션 추가 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + success: { + type: "object", + properties: { + id: { type: "number" }, + content: { type: "string" } + } + } + } + } + } + } + }; + */ console.log("가게에 미션 추가를 요청했습니다!"); - console.log("params (restaurantId):", req.params); - console.log("body (point, content, deadline):", req.body); - const missionData = bodyToMission(req.body, req.params); const newMission = await createMission(missionData); - res.status(StatusCodes.CREATED).success(newMission); }; export const handleChallengeMission = async (req, res, next) => { + /* + #swagger.summary = '미션 도전하기 API'; + #swagger.security = [{ "bearerAuth": [] }] + #swagger.parameters['missionId'] = { description: '미션 ID', type: 'number' }; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + } + } + } + } + }; + #swagger.responses[201] = { + description: "미션 도전 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + success: { + type: "object", + properties: { + id: { type: "number" }, + status: { type: "string", example: "진행중" } + } + } + } + } + } + } + }; + */ console.log("미션 도전하기를 요청했습니다"); console.log("params (missionId):", req.params); - console.log("body (userId):", req.body); + console.log("user (from jwt):", req.user); // 토큰 정보 확인용 - const challengeData = bodyToChallenge(req.body, req.params); - const newChallenge = await challengeMission(challengeData); + // req.user.id를 DTO의 3번째 인자로 전달 + const challengeData = bodyToChallenge(req.body, req.params, req.user.id); + const newChallenge = await challengeMission(challengeData); res.status(StatusCodes.CREATED).success(newChallenge); +}; + +export const handleCompleteMission = async (req, res, next) => { + /* + #swagger.summary = '미션 완료하기 API'; + #swagger.parameters['userMissionId'] = { description: '도전 내역 ID', type: 'number' }; + #swagger.responses[200] = { + description: "미션 완료 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + success: { + type: "object", + properties: { + id: { type: "number" }, + status: { type: "string", example: "진행완료" } + } + } + } + } + } + } + }; + */ + console.log("미션 완료를 요청했습니다!"); + const { userMissionId } = paramsToCompleteMission(req.params); + const completedMission = await completeMission(userMissionId); + res.status(StatusCodes.OK).success(completedMission); }; \ No newline at end of file diff --git a/src/controllers/restaurant.controller.js b/src/controllers/restaurant.controller.js index 4beadb7..1ba06b5 100644 --- a/src/controllers/restaurant.controller.js +++ b/src/controllers/restaurant.controller.js @@ -3,22 +3,101 @@ import { bodyToRestaurant } from "../dtos/restaurant.dto.js"; import { createRestaurant, listRestaurantReviews, - } from "../services/restaurant.service.js"; +} from "../services/restaurant.service.js"; export const handleAddRestaurant = async (req, res, next) => { + /* + #swagger.summary = '가게 추가 API'; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", example: "제미니 식당" }, + address: { type: "string", example: "서울시 마포구" }, + detailAddress: { type: "string", example: "101호" }, + phoneNumber: { type: "string", example: "010-0000-0000" }, + regionId: { type: "number", example: 1 }, + categoryId: { type: "number", example: 1 } + } + } + } + } + }; + #swagger.responses[201] = { + description: "가게 추가 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + id: { type: "number" }, + name: { type: "string" }, + regionName: { type: "string" }, + categoryName: { type: "string" } + } + } + } + } + } + } + }; + */ console.log("가게 추가를 요청했습니다"); console.log("body:", req.body); const restaurantData = bodyToRestaurant(req.body); const newRestaurant = await createRestaurant(restaurantData); - res.status(StatusCodes.CREATED).success(newRestaurant); }; export const handleListRestaurantReviews = async (req, res, next) => { - const reviews = await listRestaurantReviews( - parseInt(req.params.restaurantId), - typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 - ); + /* + #swagger.summary = '가게 리뷰 목록 조회 API'; + #swagger.parameters['restaurantId'] = { description: '가게 ID', type: 'number' }; + #swagger.parameters['cursor'] = { description: '페이징 커서', type: 'number', required: false }; + #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" }, + content: { type: "string" }, + user: { type: "object", properties: { name: { type: "string" } } } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; + */ + const restaurantId = parseInt(req.params.restaurantId); + const cursor = typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0; + + const reviews = await listRestaurantReviews(restaurantId, cursor); res.status(StatusCodes.OK).success(reviews); }; \ No newline at end of file diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index 5a93892..2f4ceb8 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -3,12 +3,77 @@ import { bodyToReview } from "../dtos/review.dto.js"; import { createReview } from "../services/review.service.js"; export const handleAddReview = async (req, res, next) => { + /* + #swagger.summary = '리뷰 작성 API'; + #swagger.security = [{ "bearerAuth": [] }]; + #swagger.parameters['restaurantId'] = { description: '가게 ID', type: 'number' }; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + content: { type: "string", example: "정말 맛있어요!" }, + rating: { type: "number", example: 4.5 } + } + } + } + } + }; + #swagger.responses[201] = { + description: "리뷰 작성 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", nullable: true, example: null }, + success: { + type: "object", + properties: { + id: { type: "number" }, + content: { type: "string" }, + rating: { type: "number" } + } + } + } + } + } + } + }; + #swagger.responses[400] = { + description: "가게가 존재하지 않음", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "R001" }, + reason: { type: "string", example: "존재하지 않는 가게입니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + */ console.log("리뷰 추가를 요청했습니다"); console.log("params (restaurantId):", req.params); - console.log("body (userId, content, rating):", req.body); + console.log("body (content, rating):", req.body); + console.log("user (from jwt):", req.user); // 토큰에서 추출한 유저 정보 확인 - const reviewData = bodyToReview(req.body, req.params); - const newReview = await createReview(reviewData); + // req.user.id를 DTO의 3번째 인자로 전달 + const reviewData = bodyToReview(req.body, req.params, req.user.id); + const newReview = await createReview(reviewData); res.status(StatusCodes.CREATED).success(newReview); }; \ No newline at end of file diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index f355ba6..de3071b 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -4,28 +4,207 @@ import { userSignUp, listUserReviews, listUserMissions, - } from "../services/user.service.js"; + updateMyInfo, +} 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", example: "test@example.com" }, + name: { type: "string", example: "홍길동" }, + gender: { type: "string", example: "MALE" }, + birth: { type: "string", example: "1999-01-01" }, + address: { type: "string", example: "서울시 강남구" }, + detailAddress: { type: "string", example: "101호" }, + phoneNumber: { type: "string", example: "010-1234-5678" }, + preferences: { type: "array", items: { type: "integer" }, example: [1, 3] } + } + } + } + } + }; + #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: "U001" }, + reason: { type: "string", example: "이미 존재하는 이메일입니다." }, + data: { type: "object" } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + */ console.log("회원가입을 요청했습니다!"); - console.log("body:", req.body); + console.log("body:", req.body); const user = await userSignUp(bodyToUser(req.body)); res.status(StatusCodes.OK).success(user); }; export const handleListUserReviews = async (req, res, next) => { + /* + #swagger.summary = '내가 작성한 리뷰 목록 조회 API'; + #swagger.parameters['userId'] = { description: '사용자 ID', type: 'number' }; + #swagger.parameters['cursor'] = { description: '페이징 커서 (리뷰 ID)', type: 'number', required: false }; + #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: { + reviewId: { type: "number" }, + content: { type: "string" }, + rating: { type: "number" }, + restaurantName: { type: "string" } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; + */ const reviews = await listUserReviews( - parseInt(req.params.userId), - typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 - ); - res.status(StatusCodes.OK).success(reviews); + parseInt(req.params.userId), + typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 + ); + res.status(StatusCodes.OK).success(reviews); }; export const handleListUserMissions = async (req, res, next) => { + /* + #swagger.summary = '내가 진행 중인 미션 목록 조회 API'; + #swagger.parameters['userId'] = { description: '사용자 ID', type: 'number' }; + #swagger.parameters['cursor'] = { description: '페이징 커서', type: 'number', required: false }; + #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: { + userMissionId: { type: "number" }, + status: { type: "string", example: "진행중" }, + mission: { + type: "object", + properties: { + point: { type: "number" }, + content: { type: "string" } + } + } + } + } + }, + pagination: { type: "object", properties: { cursor: { type: "number", nullable: true } }} + } + } + } + } + } + } + }; + */ const missions = await listUserMissions( parseInt(req.params.userId), typeof req.query.cursor === "string" ? parseInt(req.query.cursor) : 0 ); res.status(StatusCodes.OK).success(missions); +}; + +// 내 정보 수정 컨트롤러 +export const handleUpdateMyInfo = async (req, res, next) => { + /* + #swagger.summary = '내 정보 수정 API'; + #swagger.security = [{ "bearerAuth": [] }] + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", example: "홍길동" }, + gender: { type: "string", example: "MALE" }, + birth: { type: "string", example: "1999-01-01" }, + address: { type: "string", example: "서울시 강남구" }, + detailAddress: { type: "string", example: "101호" }, + phoneNumber: { type: "string", example: "010-1234-5678" } + } + } + } + } + }; + */ + console.log("내 정보 수정을 요청했습니다!"); + console.log("body:", req.body); + + // 🚨 중요: userId는 req.body가 아니라 req.user.id (토큰)에서 가져옵니다. + const updatedUser = await updateMyInfo(req.user.id, req.body); + res.status(StatusCodes.OK).success(updatedUser); }; \ No newline at end of file diff --git a/src/dtos/mission.dto.js b/src/dtos/mission.dto.js index 9969f2d..940f9d7 100644 --- a/src/dtos/mission.dto.js +++ b/src/dtos/mission.dto.js @@ -19,9 +19,9 @@ export const responseFromMission = (data) => { }; // 미션 도전하기 요청 DTO -export const bodyToChallenge = (body, params) => { +export const bodyToChallenge = (body, params, userId) => { return { - userId: body.userId, + userId: userId, missionId: params.missionId, }; }; @@ -35,4 +35,10 @@ export const responseFromUserMission = (data) => { status: data.status, createdAt: data.created_at, }; +}; + +export const paramsToCompleteMission = (params) => { + return { + userMissionId: parseInt(params.userMissionId), + }; }; \ No newline at end of file diff --git a/src/dtos/review.dto.js b/src/dtos/review.dto.js index 83e39b0..1071b20 100644 --- a/src/dtos/review.dto.js +++ b/src/dtos/review.dto.js @@ -1,10 +1,10 @@ -export const bodyToReview = (body, params) => { +export const bodyToReview = (body, params, userId) => { return { - userId: parseInt(body.userId), - restaurantId: parseInt(params.restaurantId), + userId: userId, // 컨트롤러에서 넘겨준 토큰 ID 사용 + restaurantId: parseInt(params.restaurantId), content: body.content, - rating: body.rating, + rating: body.rating, }; }; diff --git a/src/dtos/user.dto.js b/src/dtos/user.dto.js index 6f7332d..5c1a88c 100644 --- a/src/dtos/user.dto.js +++ b/src/dtos/user.dto.js @@ -42,4 +42,17 @@ export const responseFromUserMissions = (missions) => { cursor: missions.length ? missions[missions.length - 1].id : null, }, }; +}; + +// 사용자 정보 수정 요청 +export const bodyToUserUpdate = (body) => { + return { + name: body.name || undefined, + gender: body.gender || undefined, + birth: body.birth ? new Date(body.birth) : undefined, + address: body.address || undefined, + detailAddress: body.detailAddress || undefined, + phoneNumber: body.phoneNumber || undefined, + preferences: body.preferences || [], + }; }; \ No newline at end of file diff --git a/src/error.js b/src/error.js index bbd7f51..0007284 100644 --- a/src/error.js +++ b/src/error.js @@ -1,6 +1,7 @@ // 이메일 중복 export class DuplicateUserEmailError extends Error { errorCode = "U001"; + statusCode = 400; constructor(reason, data) { super(reason); @@ -12,6 +13,7 @@ export class DuplicateUserEmailError extends Error { // 존재하지 않는 가게 export class RestaurantNotFoundError extends Error { errorCode = "R001"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -22,7 +24,8 @@ export class RestaurantNotFoundError extends Error { // 존재하지 않는 미션 export class MissionNotFoundError extends Error { - errorCode = "M002"; + errorCode = "M001"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -31,10 +34,24 @@ export class MissionNotFoundError extends Error { } } +// 존재하지 않는 유저 미션 +export class MissionUserNotFoundError extends Error { + errorCode = "M002"; + statusCode = 404; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } + + +} // 존재하지 않는 유저 export class UserNotFoundError extends Error { errorCode = "U002"; + statusCode = 404; constructor(reason, data) { super(reason); @@ -58,11 +75,24 @@ export class InternalServerError extends Error { // 이미 도전중인 미션 export class MissionAlreadyChallengedError extends Error { + errorCode = "M003"; + statusCode = 400; + + constructor(reason, data) { + super(reason); + this.reason = reason; + this.data = data; + } +} + +// 이미 완료한 미션 +export class MissionAlreadyCompletedError extends Error { errorCode = "M004"; + statusCode = 400; constructor(reason, data) { super(reason); this.reason = reason; this.data = data; } -} \ No newline at end of file +} diff --git a/src/index.js b/src/index.js index 5e79fb8..f194c98 100644 --- a/src/index.js +++ b/src/index.js @@ -3,11 +3,17 @@ import dotenv from "dotenv"; import express from "express"; import morgan from "morgan"; import cookieParser from "cookie-parser"; +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 { handleUserSignUp, handleListUserReviews, handleListUserMissions, + handleUpdateMyInfo, } from "./controllers/user.controller.js"; import { handleAddRestaurant, @@ -16,11 +22,15 @@ import { import { handleAddReview } from "./controllers/review.controller.js"; import { handleAddMission, - handleChallengeMission + handleChallengeMission, + handleCompleteMission, } from "./controllers/mission.controller.js"; dotenv.config(); +passport.use(googleStrategy); +passport.use(jwtStrategy); + const app = express(); const port = process.env.PORT; @@ -50,19 +60,86 @@ app.use(express.static("public")); // 정적 파일 접근 app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 app.use(cookieParser()); +app.use(passport.initialize()); + +app.use( + "/docs", + swaggerUiExpress.serve, + swaggerUiExpress.setup({}, { + swaggerOptions: { + url: "/openapi.json", + }, + }) +); + +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 테스트 프로젝트입니다.", + }, + host: "localhost:3000", + }; + + const result = await swaggerAutogen(options)(outputFile, routes, doc); + res.json(result ? result.data : null); +}); + +const isLogin = passport.authenticate("jwt", { session: false }); + +// ... app.get("/", (req, res) => { res.send("Hello World!"); }); app.post("/api/v1/users/signup", handleUserSignUp); -app.post("/api/v1/restaurants", handleAddRestaurant); -app.post("/api/v1/restaurants/:restaurantId/reviews", handleAddReview); -app.post("/api/v1/restaurants/:restaurantId/missions", handleAddMission); -app.post("/api/v1/missions/:missionId/challenge", handleChallengeMission); +app.post("/api/v1/restaurants", isLogin, handleAddRestaurant); +app.post("/api/v1/restaurants/:restaurantId/reviews", isLogin, handleAddReview); +app.post("/api/v1/restaurants/:restaurantId/missions",isLogin, handleAddMission); +app.post("/api/v1/missions/:missionId/challenge", isLogin, handleChallengeMission); app.get("/api/v1/restaurants/:restaurantId/reviews", handleListRestaurantReviews); app.get("/api/v1/users/:userId/reviews", handleListUserReviews); app.get("/api/v1/users/:userId/missions", handleListUserMissions) +app.patch("/api/v1/missions/:userMissionId/complete", isLogin, handleCompleteMission); +app.patch("/api/v1/users/me", isLogin, handleUpdateMyInfo); + +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, + } + }); + + } +); + + +app.get('/mypage', isLogin, (req, res) => { + res.status(200).success({ + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + user: req.user, + }); +}); /** * 전역 오류를 처리하기 위한 미들웨어 diff --git a/src/repositories/mission.repository.js b/src/repositories/mission.repository.js index 9ff8424..a915714 100644 --- a/src/repositories/mission.repository.js +++ b/src/repositories/mission.repository.js @@ -40,7 +40,7 @@ export const getMissionById = async (missionId) => { // 사용자가 특정 미션에 도전 중인지 확인 (검증용) export const checkUserMissionExists = async (userId, missionId) => { - const existingMission = await prisma.user_mission.findUnique({ + const existingMission = await prisma.userMission.findUnique({ where: { userId_missionId: { userId: userId, @@ -78,10 +78,49 @@ export const addUserMission = async (data) => { throw new InternalServerError(`DB 오류가 발생했습니다: ${err.message}`); } }; -// ID로 user_mission 정보 조회 (방금 추가한 '도전' 확인용) +// ID로 user_mission 정보 조회 export const getUserMissionById = async (userMissionId) => { - const userMission = await prisma.user_mission.findUnique({ - where: { id: userMissionId }, - }); - return userMission; + try { + const userMission = await prisma.userMission.findUnique({ + where: { id: userMissionId }, + select: { + id: true, + status: true, + userId: true, + missionId: true, + user: true, + mission: true, + }, + }); + + return userMission; + + } catch (err) { + throw new InternalServerError(`DB 오류가 발생했습니다: ${err.message}`, err); + } +}; + +// 미션 완료 처리 +export const completeUserMission = async (userMissionId, userId, points) => { + try { + const result = await prisma.$transaction(async (tx) => { + // 미션 상태를 '진행 완료'로 변경 + const updateMission = await tx.userMission.update({ + where: { id: userMissionId }, + data: { status: '진행 완료' }, + }); + + // 유저 포인트 증가 + await tx.user.update({ + where: { id: userId }, + data: { point: { increment: points }, }, + }); + + return updateMission; + }); + return result; + } catch (err) { + console.error(err); + throw new InternalServerError(`DB 오류가 발생했습니다: ${err.message}`); + } }; \ No newline at end of file diff --git a/src/repositories/restaurant.repository.js b/src/repositories/restaurant.repository.js index c1eef19..618cace 100644 --- a/src/repositories/restaurant.repository.js +++ b/src/repositories/restaurant.repository.js @@ -4,6 +4,7 @@ import { InternalServerError, } from "../error.js"; +// 가게 데이터 삽입 export const addRestaurant = async (data) => { try { const newRestaurant = await prisma.restaurant.create({ @@ -31,6 +32,7 @@ export const addRestaurant = async (data) => { } }; +// ID로 가게 정보 얻기 export const getRestaurantById = async (restaurantId) => { const restaurant = await prisma.restaurant.findUnique({ where: { id: restaurantId }, @@ -43,6 +45,7 @@ export const getRestaurantById = async (restaurantId) => { return restaurant; }; +// 특정 가게의 리뷰 목록 조회 export const getAllRestaurantReviews = async (restaurantId, cursor) => { const reviews = await prisma.review.findMany({ select: { diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js index 39c50a1..23aa69c 100644 --- a/src/repositories/user.repository.js +++ b/src/repositories/user.repository.js @@ -76,4 +76,20 @@ export const getAllUserMissions = async (userId, cursor) => { take: 5, }); return missions; +}; + +// 유저 정보 수정 +export const updateUserInfo = async (userId, data) => { + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + name: data.name, + gender: data.gender, + birth: data.birth, + address: data.address, + detailAddress: data.detailAddress, + phoneNumber: data.phoneNumber, + }, + }); + return updatedUser; }; \ No newline at end of file diff --git a/src/services/mission.service.js b/src/services/mission.service.js index dddd20f..a5e9f87 100644 --- a/src/services/mission.service.js +++ b/src/services/mission.service.js @@ -10,15 +10,16 @@ import { checkUserMissionExists, addUserMission, getUserMissionById, + completeUserMission, } from "../repositories/mission.repository.js"; import { getRestaurantById } from "../repositories/restaurant.repository.js"; import { getUser } from "../repositories/user.repository.js"; import { - MissionRestaurantNotFoundError, + RestaurantNotFoundError, MissionNotFoundError, - MissionUserNotFoundError, - MissionAlreadyChallengedError, + UserNotFoundError, + MissionAlreadyCompletedError, } from "../error.js"; export const createMission = async (data) => { @@ -51,7 +52,7 @@ export const challengeMission = async (data) => { const user = await prisma.user.findUnique({ where: { id: userId } }); if (user === null) { - throw new MissionUserNotFoundError( + throw new UserNotFoundError( `존재하지 않는 사용자입니다. (ID: ${userId})` ); } @@ -59,4 +60,32 @@ export const challengeMission = async (data) => { const newUserMission = await addUserMission(data); return responseFromUserMission(newUserMission); -}; \ No newline at end of file +}; + +// 미션 완료하기 서비스 +export const completeMission = async (userMissionId) => { + // 해당 도전 내역이 존재하는 지 확인 + const userMission = await getUserMissionById(userMissionId); + + if (!userMission) { + throw new UserMissionNotFoundError( + `존재하지 않는 유저 미션입니다. (ID: ${userMissionId})` + ); + } + + // 이미 완료된 미션인지 확인 + if (userMission.status === "진행완료") { + throw new MissionAlreadyCompletedError( + `이미 완료된 미션입니다. (유저미션 ID: ${userMissionId})` + ); + } + + // 미션 완료 처리 및 포인트 지급 + const completed = await completeUserMission( + userMission.id, + userMission.userId, + userMission.mission.point + ); + + return responseFromUserMission(completed); +}; diff --git a/src/services/user.service.js b/src/services/user.service.js index eba9076..00e3986 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -2,6 +2,7 @@ import { responseFromUser, responseFromReviews, responseFromUserMissions, + bodyToUserUpdate } from '../dtos/user.dto.js'; import { addUser, @@ -10,7 +11,9 @@ import { setPreference, getAllUserReviews, getAllUserMissions, + updateUserInfo, } from "../repositories/user.repository.js"; +import { DuplicateUserEmailError } from "../error.js"; export const userSignUp = async (data) => { const joinUserId = await addUser({ @@ -46,4 +49,15 @@ export const listUserMissions = async (userId, cursor) => { console.log(`[Service] Got userId: ${userId}, Got cursor: ${cursor}`); const missions = await getAllUserMissions(userId, cursor);   return responseFromUserMissions(missions); +}; + +// 사용자 정보 수정 서비스 +export const updateMyInfo = async (userId, body) => { + const updateData = bodyToUserUpdate(body); + + const updatedUser = await updateUserInfo(userId, updateData); + + const preferences = await getUserPreferencesByUserId(userId); + + return responseFromUser({ user: updatedUser, preferences }); }; \ No newline at end of file