diff --git a/package-lock.json b/package-lock.json index 48ca09a..0e60777 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", @@ -291,6 +347,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", @@ -394,6 +456,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", @@ -459,6 +530,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", @@ -643,6 +723,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", @@ -715,6 +801,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", @@ -798,6 +905,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", @@ -834,6 +952,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", @@ -915,6 +1130,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", @@ -1050,6 +1277,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", @@ -1116,6 +1349,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", @@ -1132,6 +1442,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", @@ -1307,6 +1622,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", @@ -1445,6 +1772,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", @@ -1474,6 +1837,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", @@ -1483,6 +1852,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 7f55435..1e16b77 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,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.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" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a317b6..926107b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { provider String? @db.VarChar(50) providerId String? @map("provider_id") @db.VarChar(100) preferences UserPrefer[] + reviews Review[] @@map("user") } @@ -75,6 +76,7 @@ model Review { body String? @db.Text createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id]) store Store @relation(fields: [storeId], references: [id]) @@index([storeId], map: "review_store_id_fkey") diff --git a/src/auth.config.js b/src/auth.config.js new file mode 100644 index 0000000..101e800 --- /dev/null +++ b/src/auth.config.js @@ -0,0 +1,109 @@ +import dotenv from "dotenv"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { prisma } from "./db.config.js"; +import jwt from "jsonwebtoken"; // JWT 생성을 위해 import +import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; + +dotenv.config(); +const secret = process.env.JWT_SECRET; // .env의 비밀 키 + +export const generateAccessToken = (user) => { + return jwt.sign({ id: Number(user.id), email: user.email }, secret, { + expiresIn: "1h", + }); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign({ id: Number(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: null, + birth: new Date(1970, 0, 1), + address: "추후 수정", + specAddress: "추후 수정", + passwordHash: "GOOGLE_LOGIN", + status: null, + inactiveDate: null, + point: 0, + provider: "google", + providerId: profile.id, + }, + }); + + 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: "/auth/google/callback", + 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: BigInt(payload.id) }, + }); + + if (!user) { + return done(null, false); + } + + const safeUser = { + id: Number(user.id), + email: user.email, + name: user.name, + provider: user.provider, + }; + + return done(null, safeUser); + } catch (err) { + return done(err, false); + } + } +); diff --git a/src/controllers/mission.controller.js b/src/controllers/mission.controller.js index ba31bea..61e58f7 100644 --- a/src/controllers/mission.controller.js +++ b/src/controllers/mission.controller.js @@ -13,6 +13,129 @@ import { serialize } from "../utils/serialize.js"; // POST /stores/:storeId/missions export const handleAddMission = async (req, res, next) => { + /* + #swagger.tags = ['Store'] + #swagger.summary = '가게에 미션 추가 API' + #swagger.description = ':storeId에 해당하는 가게에 새로운 미션을 추가' + + #swagger.parameters['storeId'] = { + in: 'path', + required: true, + description: '미션을 추가할 가게 ID', + schema: { type: 'number' }, + example: 3 + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["reward", "missionSpec"], + properties: { + reward: { type: "number", example: 500, description: "미션 보상 포인트" }, + deadline: { + type: "string", + example: "2025-01-01 23:59:59", + description: "마감 기한 (YYYY-MM-DD 또는 YYYY-MM-DD HH:MM:SS)" + }, + missionSpec: { + type: "string", + example: "가게 방문 후 리뷰 남기기", + description: "미션 내용 설명" + } + } + } + } + } + } + + #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", example: 10 }, + storeId: { type: "number", example: 3 }, + reward: { type: "number", example: 500 }, + deadline: { type: "string", example: "2025-01-01 23:59:59" }, + missionSpec: { type: "string", example: "가게 방문 후 리뷰 남기기" }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "reward, missionSpec, deadline 형식이 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "reward는 양의 숫자여야 합니다." + }, + data: { + type: "object", + example: { reward: -100 } + } + } + }, + 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: "S001" }, + reason: { + type: "string", + example: "가게를 찾을 수 없습니다." + }, + data: { + type: "object", + example: { storeId: 999 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { const mission = await addMissionToStore( req.body, @@ -26,8 +149,128 @@ export const handleAddMission = async (req, res, next) => { // POST /missions/:missionId/challenge export const handleChallengeMission = async (req, res, next) => { + /* + #swagger.tags = ['Mission'] + #swagger.summary = '미션 도전 API' + #swagger.description = ':missionId 미션에 현재 사용자가 도전' + + #swagger.parameters['missionId'] = { + in: 'path', + required: true, + description: '도전할 미션 ID', + schema: { type: 'number' }, + example: 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: { + userId: { type: "number", example: 1 }, + missionId: { type: "number", example: 5 }, + status: { type: "string", example: "IN_PROGRESS" }, + startedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + completedAt: { type: "string", nullable: true, example: null }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" } + } + } + } + } + } + } + }; + + #swagger.responses[404] = { + description: "미션 또는 유저가 존재하지 않는 경우", + content: { + "application/json": { + schema: { + type: "object", + oneOf: [ + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "M001" }, + reason: { + type: "string", + example: "미션을 찾을 수 없습니다." + }, + data: { type: "object", example: { missionId: 999 } } + } + }, + success: { type: "object", nullable: true, example: null } + } + }, + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "U002" }, + reason: { + type: "string", + example: "회원가입을 먼저 해주세요." + }, + data: { type: "object", example: {} } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + ] + } + } + } + } + + #swagger.responses[409] = { + description: "이미 도전 중이거나 완료한 미션인 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { + type: "string", + example: "M002" + }, + reason: { + type: "string", + example: "이미 도전 중인 미션입니다." + }, + data: { + type: "object", + example: { missionId: 1, userId: 1 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { - const um = await challengeMission(Number(req.params.missionId)); + const userId = req.user.id; + const um = await challengeMission(userId, Number(req.params.missionId)); res.status(StatusCodes.CREATED).success(responseFromUserMission(um)); } catch (err) { next(err); @@ -36,6 +279,115 @@ export const handleChallengeMission = async (req, res, next) => { // 진행 중인 미션 목록 조회 export const handleListUserMissions = async (req, res, next) => { + /* + #swagger.tags = ['Mission'] + #swagger.summary = '특정 유저 진행 중 미션 목록 조회 API' + #swagger.description = ':userId 사용자가 진행 중(IN_PROGRESS)인 미션 목록을 조회' + + #swagger.parameters['userId'] = { + in: 'path', + required: true, + description: '유저 ID', + schema: { type: 'number' }, + example: 1 + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + description: '다음 페이지 조회를 위한 커서 (user_mission.missionId)', + schema: { type: 'number' }, + example: 0 + } + + #swagger.parameters['take'] = { + 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: { + userId: { type: "number", example: 1 }, + missionId: { type: "number", example: 5 }, + status: { type: "string", example: "IN_PROGRESS" }, + startedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + completedAt: { type: "string", nullable: true, example: null }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + mission: { + type: "object", + nullable: true, + properties: { + id: { type: "number", example: 5 }, + storeId: { type: "number", example: 3 }, + reward: { type: "number", example: 500 }, + missionSpec: { type: "string", example: "가게 방문 후 리뷰 남기기" } + } + } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", nullable: true, example: 5 } + } + } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "userId, cursor, take 값이 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "userId는 숫자여야 합니다." + }, + data: { + type: "object", + example: { userId: "abc", cursor: -1, take: 0 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { const userId = Number(req.params.userId); const cursor = @@ -58,6 +410,107 @@ export const handleListUserMissions = async (req, res, next) => { }; export const handleCompleteUserMission = async (req, res, next) => { + /* + #swagger.tags = ['Mission'] + #swagger.summary = '미션 완료 처리 API' + #swagger.description = ':userId 사용자의 :missionId 미션을 완료 상태로 변경' + + #swagger.parameters['userId'] = { + in: 'path', + required: true, + description: '유저 ID', + schema: { type: 'number' }, + example: 1 + } + + #swagger.parameters['missionId'] = { + in: 'path', + required: true, + description: '완료 처리할 미션 ID', + 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: { + userId: { type: "number", example: 1 }, + missionId: { type: "number", example: 5 }, + status: { type: "string", example: "COMPLETED" }, + startedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + completedAt: { type: "string", example: "2025-01-02T00:00:00.000Z" }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-02T00:00:00.000Z" } + } + } + } + } + } + } + } + + #swagger.responses[409] = { + description: "이미 완료했거나, 도전 기록이 없는 경우", + content: { + "application/json": { + schema: { + type: "object", + oneOf: [ + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "M003" }, + reason: { + type: "string", + example: "이미 완료한 미션입니다." + }, + data: { + type: "object", + example: { missionId: 5, userId: 1 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + }, + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "C000" }, + reason: { + type: "string", + example: "해당 유저의 미션 도전 기록이 없습니다." + }, + data: { + type: "object", + example: { missionId: 5, userId: 1 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + ] + } + } + } + } + */ try { const userId = Number(req.params.userId); const missionId = Number(req.params.missionId); diff --git a/src/controllers/review.controller.js b/src/controllers/review.controller.js index 2310806..6ea5438 100644 --- a/src/controllers/review.controller.js +++ b/src/controllers/review.controller.js @@ -4,12 +4,149 @@ import { listMyReviews, listReviewsByUserId, } from "../services/review.service.js"; -import { responseFromReview } from "../dtos/review.dto.js"; +import { toPlainReview } from "../dtos/review.dto.js"; export const handleAddReview = async (req, res, next) => { + /* + #swagger.tags = ['Review'] + #swagger.summary = '리뷰 작성 API' + #swagger.description = ':storeId 가게에 리뷰를 작성' + + #swagger.parameters['storeId'] = { + in: 'path', + required: true, + description: '리뷰를 작성할 가게 ID', + schema: { type: 'number' }, + example: 3 + } + + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["score", "body"], + properties: { + score: { type: "number", example: 5, description: "1~5 사이 점수" }, + body: { type: "string", example: "너무 맛있어요!" } + } + } + } + } + } + + #swagger.responses[201] = { + description: "리뷰 작성 성공", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "SUCCESS" }, + error: { type: "object", example: null }, + success: { + type: "object", + properties: { + id: { type: "number", example: 12 }, + score: { type: "number", example: 5 }, + body: { type: "string", example: "너무 맛있어요!" }, + userId: { type: "number", example: 1 }, + storeId: { type: "number", example: 3 }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" } + } + } + } + } + } + } + } + #swagger.responses[400] = { + description: "요청 값이 유효하지 않은 경우 (score 범위, body 필수 등)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "score는 1~5 사이의 숫자여야 합니다." + }, + data: { + type: "object", + example: { score: 10 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + + #swagger.responses[404] = { + description: "가게 또는 유저가 없는 경우", + content: { + "application/json": { + schema: { + type: "object", + oneOf: [ + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "S001" }, + reason: { + type: "string", + example: "가게를 찾을 수 없습니다." + }, + data: { + type: "object", + example: { storeId: 999 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + }, + { + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "U002" }, + reason: { + type: "string", + example: "사용자를 찾을 수 없습니다." + }, + data: { + type: "object", + example: {} + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + ] + } + } + } + } + */ try { const review = await addReviewToStore(req.body, Number(req.params.storeId)); - res.status(StatusCodes.CREATED).success(responseFromReview(review)); + res.status(StatusCodes.CREATED).success(toPlainReview(review)); } catch (err) { next(err); } @@ -17,8 +154,121 @@ export const handleAddReview = async (req, res, next) => { // 내 리뷰 조회 export const handleListMyReviews = async (req, res, next) => { + /* + #swagger.tags = ['Review'] + #swagger.summary = '내 리뷰 목록 조회 API' + #swagger.description = '현재 로그인된 사용자의 리뷰 목록을 조회' + + #swagger.parameters['userId'] = { + in: 'query', + required: true, + description: '내 리뷰를 조회할 사용자 ID (임시: 인증 미구현으로 query 사용)', + schema: { type: 'number' }, + example: 1 + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + description: '다음 페이지 조회 커서 (리뷰 id)', + schema: { type: 'number' }, + example: 0 + } + + #swagger.parameters['take'] = { + 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", example: null }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number", example: 1 }, + body: { type: "string", example: "맛있어요!" }, + score: { type: "number", example: 5 }, + store: { + type: "object", + properties: { + id: { type: "number", example: 3 }, + name: { type: "string", example: "가게이름" } + } + }, + createdAt: { type: "string" }, + updatedAt: { type: "string" } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", nullable: true, example: 10 } + } + } + } + } + } + } + } + } + } + + #swagger.responses[404] = { + description: "유저가 존재하지 않는 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "U002" }, + reason: { + type: "string", + example: "사용자가 없습니다. 먼저 회원가입을 진행하세요." + }, + data: { + type: "object", + example: {} + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { - const userIdFromAuth = null; + const rawUserId = req.query.userId; + const userIdFromAuth = + typeof rawUserId === "string" ? Number(rawUserId) : NaN; + + if (!Number.isFinite(userIdFromAuth)) { + throw new ValidationError("userId는 숫자여야 합니다.", { + userId: rawUserId, + }); + } + const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; const take = @@ -33,6 +283,109 @@ export const handleListMyReviews = async (req, res, next) => { // 특정 유저 리뷰 조회 export const handleListUserReviews = async (req, res, next) => { + /* + #swagger.tags = ['Review'] + #swagger.summary = '특정 유저 리뷰 목록 조회 API' + #swagger.description = ':userId 사용자의 리뷰 목록을 조회' + + #swagger.parameters['userId'] = { + in: 'path', + required: true, + description: '조회할 유저 ID', + schema: { type: 'number' }, + example: 2 + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + description: '다음 페이지 조회 커서', + schema: { type: 'number' }, + example: 0 + } + + #swagger.parameters['take'] = { + 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 }, + success: { + type: "object", + properties: { + data: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number", example: 1 }, + body: { type: "string", example: "맛있어요!" }, + score: { type: "number", example: 4 }, + store: { + type: "object", + properties: { + id: { type: "number", example: 3 }, + name: { type: "string", example: "가게이름" } + } + }, + createdAt: { type: "string" }, + updatedAt: { type: "string" } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", example: 10 } + } + } + } + } + } + } + } + } + } + #swagger.responses[400] = { + description: "userId가 숫자가 아닌 경우 (ValidationError)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "userId는 숫자여야 합니다." + }, + data: { + type: "object", + example: { userId: "abc" } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { const { userId } = req.params; const cursor = @@ -40,7 +393,7 @@ export const handleListUserReviews = async (req, res, next) => { const take = typeof req.query.take === "string" ? Number(req.query.take) : 5; - const result = await listReviewsByUserId(userId, cursor, take); // ← 교체 + const result = await listReviewsByUserId(userId, cursor, take); res.status(StatusCodes.OK).success(result); } catch (err) { next(err); diff --git a/src/controllers/store.controller.js b/src/controllers/store.controller.js index 47e4d82..6042806 100644 --- a/src/controllers/store.controller.js +++ b/src/controllers/store.controller.js @@ -4,6 +4,106 @@ import { listStoreMissions } from "../services/mission.service.js"; import { serialize } from "../utils/serialize.js"; export const handleCreateStore = async (req, res, next) => { + /* + #swagger.tags = ['Store'] + #swagger.summary = '가게 생성 API' + #swagger.description = '새로운 가게를 생성합니다.' + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["regionId", "name"], + properties: { + regionId: { type: "number", example: 1 }, + name: { type: "string", example: "가게이름" }, + address: { type: "string", example: "00시 00구" } + } + } + } + } + } + #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", example: 12 }, + regionId: { type: "number", example: 1 }, + name: { type: "string", example: "가게이름" }, + address: { type: "string", example: "00시 00구" } + } + } + } + } + } + } + } + #swagger.responses[400] = { + description: "요청 값이 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "regionId는 양의 정수여야 합니다." + }, + data: { + type: "object", + example: { regionId: -1 } + } + } + }, + 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: "R001" }, + reason: { + type: "string", + example: "존재하지 않는 지역입니다." + }, + data: { + type: "object", + example: { regionId: 999 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { const newStore = await createStore(req.body); res.status(StatusCodes.CREATED).success(newStore); @@ -14,13 +114,159 @@ export const handleCreateStore = async (req, res, next) => { // 리뷰 export const handleListStoreReviews = async (req, res, next) => { + /* + #swagger.tags = ['Store'] + #swagger.summary = '가게 리뷰 목록 조회' + #swagger.description = ':storeId에 해당하는 가게의 리뷰 목록을 조회합니다.' + + #swagger.parameters['storeId'] = { + in: 'path', + required: true, + description: '가게 ID', + schema: { type: 'number'}, + example: 1 + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + description: '다음 페이지 조회를 위한 커서 (리뷰 id)', + schema: { type: 'number'}, + example: 0 + } + + #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: { + id: { type: "number", example: 1 }, + body: { type: "string", example: "맛있어요!" }, + score: { type: "number", example: 5 }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + store: { + type: "object", + properties: { + id: { type: "number", example: 3 }, + name: { type: "string", example: "홍콩반점" } + } + }, + user: { + type: "object", + properties: { + id: { type: "number", example: 1 }, + name: { type: "string", example: "UMC" } + } + } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { + type: "number", + nullable: true, + example: 10, + description: "다음 페이지 조회를 위한 커서 (없으면 null)" + } + } + } + } + } + } + } + } + } + } + + #swagger.responses[400] = { + description: "storeId 또는 cursor가 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "storeId는 양의 정수여야 합니다." + }, + data: { + type: "object", + example: { storeId: -1 } + } + } + }, + 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: "S001" }, + reason: { + type: "string", + example: "가게를 찾을 수 없습니다." + }, + data: { + type: "object", + example: { storeId: 999 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { - const storeId = req.params.storeId; + const storeId = Number(req.params.storeId); const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; const reviews = await listStoreReviews(storeId, cursor); - res.status(StatusCodes.OK).success(reviews); + + const plainReviews = serialize(reviews); + + const last = plainReviews[plainReviews.length - 1]; + const nextCursor = last ? Number(last.id ?? 0) : null; + + res.status(StatusCodes.OK).success({ + data: plainReviews, + pagination: { cursor: nextCursor }, + }); } catch (err) { next(err); } @@ -28,8 +274,138 @@ export const handleListStoreReviews = async (req, res, next) => { // 미션 export const handleListStoreMissions = async (req, res, next) => { + /* + #swagger.tags = ['Store'] + #swagger.summary = '가게 미션 목록 조회' + #swagger.description = ':storeId에 해당하는 가게의 미션 목록을 조회합니다.' + + #swagger.parameters['storeId'] = { + in: 'path', + required: true, + description: '가게 ID', + schema: { type: 'number'}, + example: 3 + } + + #swagger.parameters['cursor'] = { + in: 'query', + required: false, + description: "다음 페이지 조회를 위한 커서 (mission.id)", + schema: { type: 'number' }, + example: 0, + } + + #swagger.parameters['take'] = { + in: 'query', + required: false, + schema: { type: 'number'}, + example: 5, + 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: "array", + items: { + type: "object", + properties: { + id: { type: "number", example: 10 }, + storeId: { type: "number", example: 3 }, + reward: { type: "number", example: 500 }, + deadline: { + type: "string", + example: "2025-01-01 23:59:59" + }, + missionSpec: { type: "string", example: "가게 방문 후 리뷰 남기기" }, + createdAt: { type: "string", example: "2025-01-01T00:00:00.000Z" }, + updatedAt: { type: "string", example: "2025-01-01T00:00:00.000Z" } + } + } + }, + pagination: { + type: "object", + properties: { + cursor: { type: "number", example: 10 } + } + } + } + } + } + } + } + } + } + #swagger.responses[400] = { + description: "storeId, cursor, take 값이 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "storeId는 양의 정수여야 합니다." + }, + data: { + type: "object", + example: { storeId: -1, cursor: -1, take: 0 } + } + } + }, + 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: "S001" }, + reason: { + type: "string", + example: "가게를 찾을 수 없습니다." + }, + data: { + type: "object", + example: { storeId: 123 } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + } + */ try { - const storeId = req.params.storeId; + const storeId = Number(req.params.storeId); const cursor = typeof req.query.cursor === "string" ? Number(req.query.cursor) : 0; const take = diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 45081b3..e4c69e4 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,8 +1,118 @@ import { StatusCodes } from "http-status-codes"; import { bodyToUser } from "../dtos/user.dto.js"; -import { userSignUp } from "../services/user.service.js"; +import { updateMyProfile, userSignUp } from "../services/user.service.js"; export const handleUserSignUp = async (req, res, next) => { + /* + #swagger.summary = '회원 가입 API'; + #swagger.tags = ['User']; + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["email", "name", "gender", "birth", "password"], + properties: { + email: { type: "string", example: "test@example.com" }, + name: { type: "string", example: "이름" }, + gender: { type: "string", example: "여성" }, + birth: { type: "string", format: "date", example: "2000-01-01" }, + address: { type: "string", example: "서울시" }, + detailAddress: { type: "string", example: "OO구 OO동" }, + phoneNumber: { + type: "string", + example: "010-1234-5678", + description: "현재 서버에서는 사용하지 않는 선택 필드" + }, + password: { type: "string", example: "password123" }, + preferences: { + type: "array", + items: { type: "number" }, + example: [1, 2] + } + } + } + } + } + }; + #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", example: "test@example.com" }, + name: { type: "string", example: "이름" }, + preferCategory: { + type: "array", + items: { type: "string" }, + example: ["한식", "일식"] + } + } + } + } + } + } + } + }; + #swagger.responses[400] = { + description: "요청 값이 유효하지 않은 경우", + content: { + "application/json": { + schema: { + type: "object", + properties: { + resultType: { type: "string", example: "FAIL" }, + error: { + type: "object", + properties: { + errorCode: { type: "string", example: "V001" }, + reason: { + type: "string", + example: "유효하지 않은 요청입니다." + }, + data: { + type: "object", + example: { field: "email", value: "not-an-email" } + } + } + }, + success: { type: "object", nullable: true, example: null } + } + } + } + } + }; + #swagger.responses[409] = { + 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 } + } + } + } + } + }; +*/ try { console.log("회원가입을 요청했습니다!"); console.log("body:", req.body); @@ -17,3 +127,19 @@ export const handleUserSignUp = async (req, res, next) => { next(err); } }; + +export const handleUpdateMyProfile = async (req, res, next) => { + /* + #swagger.tags = ['User'] + #swagger.summary = '내 프로필 수정 API' + #swagger.description = '로그인한 사용자의 기본 정보를 수정합니다.' + */ + try { + const userId = req.user.id; + const updated = await updateMyProfile(userId, req.body); + + res.status(StatusCodes.OK).success(updated); + } catch (err) { + next(err); + } +}; diff --git a/src/dtos/mission.dto.js b/src/dtos/mission.dto.js index 372dd5c..3e6e61b 100644 --- a/src/dtos/mission.dto.js +++ b/src/dtos/mission.dto.js @@ -34,8 +34,8 @@ export const bodyToMission = (body, storeIdFromPath) => { }; export const responseFromMission = (m) => ({ - id: m.id, - storeId: m.storeId, + id: Number(m.id), + storeId: Number(m.storeId), reward: m.reward, deadline: m.deadline, missionSpec: m.missionSpec, @@ -44,8 +44,8 @@ export const responseFromMission = (m) => ({ }); export const responseFromUserMission = (um) => ({ - userId: um.userId, - missionId: um.missionId, + userId: Number(um.userId), + missionId: Number(um.missionId), status: um.status, startedAt: um.startedAt, completedAt: um.completedAt ?? null, diff --git a/src/index.js b/src/index.js index 1d1b54a..4bb25fa 100644 --- a/src/index.js +++ b/src/index.js @@ -12,7 +12,10 @@ import { handleCompleteUserMission, handleListUserMissions, } from "./controllers/mission.controller.js"; -import { handleUserSignUp } from "./controllers/user.controller.js"; +import { + handleUpdateMyProfile, + handleUserSignUp, +} from "./controllers/user.controller.js"; import { handleAddReview, handleListMyReviews, @@ -20,9 +23,17 @@ import { } from "./controllers/review.controller.js"; import morgan from "morgan"; import cookieParser from "cookie-parser"; +import swaggerAutogen from "swagger-autogen"; +import swaggerUiExpress from "swagger-ui-express"; +import { serialize } from "./utils/serialize.js"; +import { googleStrategy, jwtStrategy } from "./auth.config.js"; +import passport from "passport"; dotenv.config(); +passport.use(googleStrategy); +passport.use(jwtStrategy); + const app = express(); const port = process.env.PORT; @@ -33,6 +44,42 @@ app.use(express.static("public")); // 정적 파일 접근 app.use(express.json()); // request의 본문을 json으로 해석할 수 있도록 함 (JSON 형태의 요청 body를 파싱하기 위함) app.use(express.urlencoded({ extended: false })); // 단순 객체 문자열 형태로 본문 데이터 해석 +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); +}); + // 응답 헬퍼 미들웨어 app.use((req, res, next) => { res.success = (success) => { @@ -52,15 +99,53 @@ app.use((req, res, next) => { // 테스트 라우터 app.get("/", (req, res) => { + // #swagger.ignore = true res.send("Hello World!"); }); app.get("/test", (req, res) => { + // #swagger.ignore = true res.send("Hello!"); }); +const isLogin = passport.authenticate("jwt", { session: false }); + +app.get("/mypage", isLogin, (req, res) => { + res.status(200).success({ + message: `인증 성공! ${req.user.name}님의 마이페이지입니다.`, + user: req.user, + }); +}); + +app.get( + "/auth/google/login", + passport.authenticate("google", { + session: false, + }) +); +app.get( + "/auth/google/callback", + 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("/setcookie", (req, res) => { + // #swagger.ignore = true // 'myCookie'라는 이름으로 'hello' 값을 가진 쿠키를 생성 res.cookie("myCookie", "hello", { maxAge: 60000 }); // 60초간 유효 res.send("쿠키가 생성되었습니다!"); @@ -68,6 +153,7 @@ app.get("/setcookie", (req, res) => { // 쿠키 읽는 라우터 app.get("/getcookie", (req, res) => { + // #swagger.ignore = true // cookie-parser 덕분에 req.cookies 객체에서 바로 꺼내 쓸 수 있음 const myCookie = req.cookies.myCookie; @@ -83,22 +169,22 @@ app.get("/getcookie", (req, res) => { app.post("/api/user/signup", handleUserSignUp); // 지역에 가게 추가 -app.post("/api/stores", handleCreateStore); +app.post("/api/stores", isLogin, handleCreateStore); // 가게에 리뷰 추가 -app.post("/api/stores/:storeId/reviews", handleAddReview); +app.post("/api/stores/:storeId/reviews", isLogin, handleAddReview); // 가게에 미션 추가 -app.post("/api/stores/:storeId/missions", handleAddMission); +app.post("/api/stores/:storeId/missions", isLogin, handleAddMission); // 미션 도전하기 -app.post("/api/missions/:missionId/challenge", handleChallengeMission); +app.post("/api/missions/:missionId/challenge", isLogin, handleChallengeMission); // 리뷰 목록 조회 app.get("/api/stores/:storeId/reviews", handleListStoreReviews); // 내 리뷰 목록 조회 -app.get("/api/me/reviews", handleListMyReviews); +app.get("/api/me/reviews", isLogin, handleListMyReviews); // 특정 유저 리뷰 목록 조회 app.get("/api/users/:userId/reviews", handleListUserReviews); @@ -107,14 +193,18 @@ app.get("/api/users/:userId/reviews", handleListUserReviews); app.get("/api/stores/:storeId/missions", handleListStoreMissions); // 내가 진행 중인 미션 목록 조회 -app.get("/api/users/:userId/missions", handleListUserMissions); +app.get("/api/users/:userId/missions", isLogin, handleListUserMissions); // 진행 중 미션 완료 처리 app.patch( "/api/users/:userId/missions/:missionId/complete", + isLogin, handleCompleteUserMission ); +// 내 정보 수정 +app.patch("/api/me/profile", isLogin, handleUpdateMyProfile); + // 에러 핸들러 app.use((err, req, res, next) => { if (res.headersSent) { @@ -123,10 +213,19 @@ app.use((err, req, res, next) => { console.error("INTERNAL ERROR:", err); + let safeData = null; + if (err.data) { + try { + safeData = serialize(err.data); + } catch { + safeData = err.data; + } + } + res.status(err.statusCode || 500).error({ errorCode: err.errorCode || "unknown", reason: err.reason || err.message || null, - data: err.data || null, + data: safeData, }); }); diff --git a/src/services/mission.service.js b/src/services/mission.service.js index ad9b358..59b3af1 100644 --- a/src/services/mission.service.js +++ b/src/services/mission.service.js @@ -33,20 +33,12 @@ export const addMissionToStore = async (rawBody, storeIdFromPath) => { }; // 미션 도전 -export const challengeMission = async (missionIdFromPath) => { +export const challengeMission = async (userIdFromAuth, missionIdFromPath) => { const missionId = ensureNumber(missionIdFromPath, "missionId"); - const mission = await getMissionById(missionId); if (!mission) throw new MissionNotFoundError(missionId); - // 현재 인증 미구현이라 첫 사용자로 대체 - const userId = await getFirstUserId(); - if (!userId) { - throw new UserNotFoundError( - undefined, - "사용자가 없습니다. 먼저 회원가입을 진행하세요." - ); - } + const userId = ensureNumber(userIdFromAuth, "userId"); const existing = await findUserMission(userId, missionId); if (existing) { diff --git a/src/services/user.service.js b/src/services/user.service.js index cdd7e3b..592efe3 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -7,14 +7,12 @@ import { setPreference, } from "../repositories/user.repository.js"; import { prisma } from "../db.config.js"; -import { DuplicateUserEmailError } from "../errors.js"; +import { DuplicateUserEmailError, ValidationError } from "../errors.js"; +import { ensureNumber, ensureString } from "../utils/validation.js"; export const userSignUp = async (data) => { // 비밀번호 검증 - const pwd = (data.password ?? "").toString().trim(); - if (!pwd) { - throw new Error("비밀번호는 필수입니다."); - } + const pwd = ensureString(data.password, "비밀번호"); // preferences 정규화 const prefs = Array.isArray(data.preferences) @@ -32,7 +30,10 @@ export const userSignUp = async (data) => { }); if (count !== ids.length) { - throw new Error("존재하지 않는 카테고리가 포함되어 있습니다."); + throw new ValidationError("존재하지 않는 카테고리가 포함되어 있습니다.", { + field: "preferences", + value: data.preferences, + }); } } @@ -65,5 +66,36 @@ export const userSignUp = async (data) => { const user = await getUser(userId); const preferences = await getUserPreferencesByUserId(userId); - return responseFromUser(user, preferences); + return responseFromUser({ user, preferences }); +}; + +export const updateMyProfile = async (userIdFromAuth, body) => { + const userId = ensureNumber(userIdFromAuth, "userId"); + + // 업데이트 가능한 필드만 추출 (부분 수정 허용) + const data = {}; + if (body.name !== undefined) data.name = body.name; + if (body.gender !== undefined) { + data.gender = body.gender === "여성" ? 0 : 1; + } + if (body.birth !== undefined) { + data.birth = new Date(body.birth); + } + if (body.address !== undefined) data.address = body.address; + if (body.specAddress !== undefined) data.specAddress = body.specAddress; + + const updated = await prisma.user.update({ + where: { id: BigInt(userId) }, + data, + }); + + return { + id: Number(updated.id), + email: updated.email, + name: updated.name, + gender: updated.gender, + birth: updated.birth, + address: updated.address, + specAddress: updated.specAddress, + }; }; diff --git a/test/test.html b/test/test.html new file mode 100644 index 0000000..b6f652e --- /dev/null +++ b/test/test.html @@ -0,0 +1,76 @@ + + + + + 내 API 테스트하기 + + +

회원가입 테스트

+ + + + + +