From fb81c77722ea596589be804235762ae4c374b364 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 21 May 2024 20:14:34 +0900 Subject: [PATCH 01/36] =?UTF-8?q?seed=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 134 +++++ app.js | 24 + data/mock.js | 22 + data/seed.js | 11 + models/product.js | 47 ++ package-lock.json | 1262 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 15 + 7 files changed, 1515 insertions(+) create mode 100644 .gitignore create mode 100644 app.js create mode 100644 data/mock.js create mode 100644 data/seed.js create mode 100644 models/product.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..989cf15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# env +.env +env.js \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..004f1cc --- /dev/null +++ b/app.js @@ -0,0 +1,24 @@ +import express from "express"; +import mongoose from "mongoose"; +import { DATABASE_URL } from "./env.js"; + +mongoose.connect(DATABASE_URL).then(() => console.log("Connected to DB")); +const app = express(); + +app.use(express.json()); + +const asyncHandler = (handler) => { + return async (req, res) => { + try { + await handler(req, res); + } catch (e) { + if (e.name === "ValidationError") { + res.status(400).send({ message: e.message }); + } else if (e.name === "CastError") { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + } else { + res.status(500).send({ message: "서버 에러입니다." }); + } + } + }; +}; diff --git a/data/mock.js b/data/mock.js new file mode 100644 index 0000000..f56e1c7 --- /dev/null +++ b/data/mock.js @@ -0,0 +1,22 @@ +const data = [ + { + favoriteCount: 0, + ownerId: 1, + images: ["https://example.com/..."], + tags: ["판다인형", "인형", "판다"], + price: 700000, + description: "판다인형 판다", + name: "판다인형", + }, + { + favoriteCount: 2, + ownerId: 2, + images: ["https://example.com/..."], + tags: ["판다인형", "인형", "판다"], + price: 7000, + description: "판다인형 안판다", + name: "판다인형 안파는 판다", + }, +]; + +export default data; diff --git a/data/seed.js b/data/seed.js new file mode 100644 index 0000000..89ec145 --- /dev/null +++ b/data/seed.js @@ -0,0 +1,11 @@ +import mongoose from "mongoose"; +import { DATABASE_URL } from "../env.js"; +import Product from "../models/product.js"; +import data from "./mock.js"; + +mongoose.connect(DATABASE_URL); + +await Product.deleteMany({}); +await Product.insertMany(data); + +mongoose.connection.close(); diff --git a/models/product.js b/models/product.js new file mode 100644 index 0000000..318abcc --- /dev/null +++ b/models/product.js @@ -0,0 +1,47 @@ +import mongoose from "mongoose"; + +const ProductSchema = new mongoose.Schema( + { + favoriteCount: { + type: Number, + default: 0, + }, + ownerId: { + type: Number, + required: true, + }, + + images: { + type: [String], + required: true, + }, + tags: { + type: [String], + required: true, + }, + price: { + type: Number, + required: true, + default: 0, + }, + description: { + type: String, + }, + name: { + type: String, + required: true, + }, + isFavorite: { + type: Boolean, + required: true, + default: false, + }, + }, + { + timestamps: true, + } +); + +const Product = mongoose.model("Product", ProductSchema); + +export default Product; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e76448a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1262 @@ +{ + "name": "QA-sprint-mission", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "express": "^4.19.2", + "mongoose": "^8.4.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", + "integrity": "sha512-dCHW/oEX0KJ4NjDULBo3JiOaK5+6axtpBbS+ao2ZInoAL9/YRQLhXzSNAFz7hP4nzLkIqsfYAK/PDE3+XHny0Q==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "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==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.7.0.tgz", + "integrity": "sha512-w2IquM5mYzYZv6rs3uN2DZTOBe2a0zXLj53TGDqwF4l6Sz/XsISrisXOJihArF9+BZ6Cq/GjVht7Sjfmri7ytQ==", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "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==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.6.2.tgz", + "integrity": "sha512-ZF9Ugo2JCG/GfR7DEb4ypfyJJyiKbg5qBYKRintebj8+DNS33CyGMkWbrS9lara+u+h+yEOGSRiLhFO/g1s1aw==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.7.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.4.0.tgz", + "integrity": "sha512-fgqRMwVEP1qgRYfh+tUe2YBBFnPO35FIg2lfFH+w9IhRGg1/ataWGIqvf/MjwM29cZ60D5vSnqtN2b8Qp0sOZA==", + "dependencies": { + "bson": "^6.7.0", + "kareem": "2.6.3", + "mongodb": "6.6.2", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", + "integrity": "sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "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==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e91d2bd --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "dependencies": { + "express": "^4.19.2", + "mongoose": "^8.4.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + }, + "type": "module", + "scripts": { + "dev": "nodemon app.js", + "start": "node app.js", + "seed": "node data/seed.js" + } +} From de043b5b69f71b5099232f521578479e0cfa9912 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 21 May 2024 20:35:41 +0900 Subject: [PATCH 02/36] =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=93=B1=EB=A1=9D,?= =?UTF-8?q?=20=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 34 ++++++++++++++++++++++++++++++++++ data/mock.js | 6 ++++-- requests.http | 20 ++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 requests.http diff --git a/app.js b/app.js index 004f1cc..ee6035a 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,7 @@ import express from "express"; import mongoose from "mongoose"; import { DATABASE_URL } from "./env.js"; +import Product from "./models/product.js"; mongoose.connect(DATABASE_URL).then(() => console.log("Connected to DB")); const app = express(); @@ -22,3 +23,36 @@ const asyncHandler = (handler) => { } }; }; + +app.get( + "/products", + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - page : 페이지 번호 + * - pageSize : 페이지 당 상품 수 + * - orderBy : 정렬 기준 favorite, recent (기본값: recent) + * - keyword : 검색 키워드 + */ + const orderBy = req.query.sort; + const count = Number(req.query.count) || 0; + + const sortOption = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + + const product = await Product.find().sort(sortOption).limit(count); + + res.send(product); + }) +); + +app.post( + "/products", + asyncHandler(async (req, res) => { + const newProduct = await Product.create(req.body); + res.status(201).send(newProduct); + }) +); + +app.listen(3000, () => { + console.log("Server is running on port 3000"); +}); diff --git a/data/mock.js b/data/mock.js index f56e1c7..be18f37 100644 --- a/data/mock.js +++ b/data/mock.js @@ -2,7 +2,7 @@ const data = [ { favoriteCount: 0, ownerId: 1, - images: ["https://example.com/..."], + images: ["https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg"], tags: ["판다인형", "인형", "판다"], price: 700000, description: "판다인형 판다", @@ -11,7 +11,9 @@ const data = [ { favoriteCount: 2, ownerId: 2, - images: ["https://example.com/..."], + images: [ + "https://view01.wemep.co.kr/wmp-product/4/879/2515748794/pm_ebifv5nrjsyf.jpg?1683280710&f=webp&w=460&h=460", + ], tags: ["판다인형", "인형", "판다"], price: 7000, description: "판다인형 안판다", diff --git a/requests.http b/requests.http new file mode 100644 index 0000000..abccb34 --- /dev/null +++ b/requests.http @@ -0,0 +1,20 @@ +GET http://localhost:3000/products + +### + +GET http://localhost:3000/products + +### + +POST http://localhost:3000/products +Content-Type: application/json + +{ + "name": "판다랑 불곰 교환원해요", + "description": "세종시청에서 교환원합니다.", + "price": 20000, + "tags": ["판다", "불곰"], + "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"], + "ownerId":1 + +} From 2f75548c7dc4f95caf60ecc2c11dce188ac0ae45 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 09:17:00 +0900 Subject: [PATCH 03/36] =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=9D=98=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/product.js | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/models/product.js b/models/product.js index 318abcc..dae54d8 100644 --- a/models/product.js +++ b/models/product.js @@ -2,40 +2,39 @@ import mongoose from "mongoose"; const ProductSchema = new mongoose.Schema( { - favoriteCount: { - type: Number, - default: 0, + name: { + type: String, + required: true, }, - ownerId: { + description: { + type: String, + }, + price: { type: Number, required: true, + default: 0, }, - - images: { + tags: { type: [String], required: true, }, - tags: { + images: { type: [String], required: true, }, - price: { + favoriteCount: { type: Number, - required: true, default: 0, }, - description: { - type: String, - }, - name: { - type: String, - required: true, - }, isFavorite: { type: Boolean, required: true, default: false, }, + ownerId: { + type: Number, + required: true, + }, }, { timestamps: true, From 75f72023e200d5c42a27c1f3f13bc5ed537ecb49 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 09:40:16 +0900 Subject: [PATCH 04/36] =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C,=20=EC=83=81=ED=92=88=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 목록 조회 페이지네이션 추가 --- app.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++---- requests.http | 22 +++++++++++++++- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index ee6035a..ea246db 100644 --- a/app.js +++ b/app.js @@ -24,27 +24,59 @@ const asyncHandler = (handler) => { }; }; +//상품 목록 조회 app.get( "/products", asyncHandler(async (req, res) => { /** * 쿼리 파라미터 - * - page : 페이지 번호 - * - pageSize : 페이지 당 상품 수 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 * - orderBy : 정렬 기준 favorite, recent (기본값: recent) * - keyword : 검색 키워드 */ - const orderBy = req.query.sort; - const count = Number(req.query.count) || 0; + const offset = Number(req.query.offset) || 0; + const limit = Number(req.query.limit) || 10; + const orderBy = req.query.orderBy; + const keyword = req.query.keyword || ""; const sortOption = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - const product = await Product.find().sort(sortOption).limit(count); + // 제목과 내용에서 키워드를 검색하는 쿼리 + const query = keyword + ? { + $or: [{ name: { $regex: keyword, $options: "i" } }, { description: { $regex: keyword, $options: "i" } }], + } + : {}; + + const products = await Product.find(query) + .select("_id name price images createdAt favoriteCount isFavorite") + .sort(sortOption) + .skip(offset) + .limit(limit); + + const totalProducts = await Product.countDocuments(query); + + res.send({ + products, + totalProducts, + currentOffset: offset, + limit: limit, + }); + }) +); + +//상품 상세 조회 +app.get( + "/products/:id", + asyncHandler(async (req, res) => { + const product = await Product.findById(req.params.id).select("-updatedAt"); res.send(product); }) ); +// 상품 등록 app.post( "/products", asyncHandler(async (req, res) => { @@ -53,6 +85,34 @@ app.post( }) ); +// 상품 수정 +app.patch( + "/products/:id", + asyncHandler(async (req, res) => { + const product = await Product.findById(req.params.id); + + if (!product) { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + return; + } + const disallowedFields = { + favoriteCount: true, + isFavorite: true, + ownerId: true, + }; + + Object.keys(req.body).forEach((key) => { + if (!disallowedFields[key]) { + product[key] = req.body[key]; + } + }); + + await product.save(); + + res.send(product); + }) +); + app.listen(3000, () => { console.log("Server is running on port 3000"); }); diff --git a/requests.http b/requests.http index abccb34..41fb525 100644 --- a/requests.http +++ b/requests.http @@ -2,7 +2,15 @@ GET http://localhost:3000/products ### -GET http://localhost:3000/products +GET http://localhost:3000/products?offset=0&limit=0&keyword=불곰 + +### + +GET http://localhost:3000/products?keyword=판다 + +### + +GET http://localhost:3000/products/664d393f71f073e051e9aaca ### @@ -16,5 +24,17 @@ Content-Type: application/json "tags": ["판다", "불곰"], "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"], "ownerId":1 +} + +### +PATCH http://localhost:3000/products/664d393f71f073e051e9aaca +Content-Type: application/json + +{ + "name":"판다 안팔려서 안판다", + "description":"안판다고 했지만 사실은 판다", + "price":7000, + "tags":["판다","안판다"] } + From 7f34ae2c03f65f6e5792c45a3650967dba6b4671 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 09:52:42 +0900 Subject: [PATCH 05/36] =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=82=AD=EC=A0=9C,?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++-- requests.http | 12 +++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index ea246db..0a00cb0 100644 --- a/app.js +++ b/app.js @@ -24,7 +24,7 @@ const asyncHandler = (handler) => { }; }; -//상품 목록 조회 +// 상품 목록 조회 app.get( "/products", asyncHandler(async (req, res) => { @@ -66,7 +66,7 @@ app.get( }) ); -//상품 상세 조회 +// 상품 상세 조회 app.get( "/products/:id", asyncHandler(async (req, res) => { @@ -113,6 +113,73 @@ app.patch( }) ); +// 상품 삭제 +app.delete( + "/products/:id", + asyncHandler(async (req, res) => { + const product = await Product.findByIdAndDelete(req.params.id); + + if (!product) { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + return; + } + + res.sendStatus(204); + }) +); + +// 상품 좋아요 +app.patch( + "/products/:id/like", + asyncHandler(async (req, res) => { + const product = await Product.findById(req.params.id); + + if (!product) { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + return; + } + + if (product.isFavorite) { + res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); + return; + } + + product.favoriteCount += 1; + product.isFavorite = true; + + await product.save(); + + res.send(product); + }) +); + +// 상품 좋아요 취소 +app.patch( + "/products/:id/unlike", + asyncHandler(async (req, res) => { + const product = await Product.findById(req.params.id); + + if (!product) { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + return; + } + + if (!product.isFavorite) { + res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); + return; + } + + if (product.favoriteCount > 0) { + product.favoriteCount -= 1; + } + product.isFavorite = false; + + await product.save(); + + res.send(product); + }) +); + app.listen(3000, () => { console.log("Server is running on port 3000"); }); diff --git a/requests.http b/requests.http index 41fb525..c2aae31 100644 --- a/requests.http +++ b/requests.http @@ -38,3 +38,15 @@ Content-Type: application/json "tags":["판다","안판다"] } + +### + +DELETE http://localhost:3000/products/664d393f71f073e051e9aaca + +### +PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/like + +### + +PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/unlike + From d5dbbdfad93b8cd7fd57bc3113382c74a12998fd Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 09:57:44 +0900 Subject: [PATCH 06/36] =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 10 +++++----- data/seed.js | 5 +++-- package-lock.json | 12 ++++++++++++ package.json | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app.js b/app.js index 0a00cb0..6b0a406 100644 --- a/app.js +++ b/app.js @@ -1,9 +1,11 @@ import express from "express"; import mongoose from "mongoose"; -import { DATABASE_URL } from "./env.js"; import Product from "./models/product.js"; -mongoose.connect(DATABASE_URL).then(() => console.log("Connected to DB")); +import * as dotenv from "dotenv"; + +dotenv.config(); +mongoose.connect(process.env.DATABASE_URL).then(() => console.log("Connected to DB")); const app = express(); app.use(express.json()); @@ -180,6 +182,4 @@ app.patch( }) ); -app.listen(3000, () => { - console.log("Server is running on port 3000"); -}); +app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/data/seed.js b/data/seed.js index 89ec145..b2fd350 100644 --- a/data/seed.js +++ b/data/seed.js @@ -1,9 +1,10 @@ +import * as dotenv from "dotenv"; import mongoose from "mongoose"; -import { DATABASE_URL } from "../env.js"; import Product from "../models/product.js"; import data from "./mock.js"; -mongoose.connect(DATABASE_URL); +dotenv.config(); +mongoose.connect(process.env.DATABASE_URL); await Product.deleteMany({}); await Product.insertMany(data); diff --git a/package-lock.json b/package-lock.json index e76448a..d6df146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "dotenv": "^16.4.5", "express": "^4.19.2", "mongoose": "^8.4.0" }, @@ -289,6 +290,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/package.json b/package.json index e91d2bd..1dec6dd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "dotenv": "^16.4.5", "express": "^4.19.2", "mongoose": "^8.4.0" }, From b26cab1fe6f998b9b522d6f52c4d76030c858e46 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 10:23:00 +0900 Subject: [PATCH 07/36] =?UTF-8?q?cors=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 15 +++++++++++---- package-lock.json | 21 +++++++++++++++++++++ package.json | 1 + 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index 6b0a406..723b901 100644 --- a/app.js +++ b/app.js @@ -1,13 +1,17 @@ +import cors from "cors"; +import * as dotenv from "dotenv"; import express from "express"; import mongoose from "mongoose"; import Product from "./models/product.js"; -import * as dotenv from "dotenv"; - dotenv.config(); mongoose.connect(process.env.DATABASE_URL).then(() => console.log("Connected to DB")); const app = express(); - +app.use(cors()); +const corsOptions = { + origin: ["http://127.0.0.1:3000", "https://panda-market.com"], +}; +app.use(cors(corsOptions)); app.use(express.json()); const asyncHandler = (handler) => { @@ -44,7 +48,6 @@ app.get( const sortOption = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - // 제목과 내용에서 키워드를 검색하는 쿼리 const query = keyword ? { $or: [{ name: { $regex: keyword, $options: "i" } }, { description: { $regex: keyword, $options: "i" } }], @@ -73,6 +76,10 @@ app.get( "/products/:id", asyncHandler(async (req, res) => { const product = await Product.findById(req.params.id).select("-updatedAt"); + if (!product) { + res.status(404).send({ message: "존재하지 않는 상품입니다." }); + return; + } res.send(product); }) diff --git a/package-lock.json b/package-lock.json index d6df146..0f2104d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "mongoose": "^8.4.0" @@ -236,6 +237,18 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -881,6 +894,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", diff --git a/package.json b/package.json index 1dec6dd..03de25d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "mongoose": "^8.4.0" From 3d86d6978b60db3359672063b29f8420516dea10 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 15:30:33 +0900 Subject: [PATCH 08/36] =?UTF-8?q?Mongo=20DB=EB=A5=BC=20PostgreSQL=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20-=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20-=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 57 +--- http/products.http | 52 +++ package-lock.json | 299 ++++++------------ package.json | 16 +- .../20240522062619_init/migration.sql | 16 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 28 ++ 7 files changed, 212 insertions(+), 259 deletions(-) create mode 100644 http/products.http create mode 100644 prisma/migrations/20240522062619_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma diff --git a/app.js b/app.js index 723b901..ebff140 100644 --- a/app.js +++ b/app.js @@ -1,17 +1,9 @@ -import cors from "cors"; -import * as dotenv from "dotenv"; +import { PrismaClient } from "@prisma/client"; import express from "express"; -import mongoose from "mongoose"; -import Product from "./models/product.js"; +const prisma = new PrismaClient(); -dotenv.config(); -mongoose.connect(process.env.DATABASE_URL).then(() => console.log("Connected to DB")); const app = express(); -app.use(cors()); -const corsOptions = { - origin: ["http://127.0.0.1:3000", "https://panda-market.com"], -}; -app.use(cors(corsOptions)); + app.use(express.json()); const asyncHandler = (handler) => { @@ -34,40 +26,8 @@ const asyncHandler = (handler) => { app.get( "/products", asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 favorite, recent (기본값: recent) - * - keyword : 검색 키워드 - */ - const offset = Number(req.query.offset) || 0; - const limit = Number(req.query.limit) || 10; - const orderBy = req.query.orderBy; - const keyword = req.query.keyword || ""; - - const sortOption = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - - const query = keyword - ? { - $or: [{ name: { $regex: keyword, $options: "i" } }, { description: { $regex: keyword, $options: "i" } }], - } - : {}; - - const products = await Product.find(query) - .select("_id name price images createdAt favoriteCount isFavorite") - .sort(sortOption) - .skip(offset) - .limit(limit); - - const totalProducts = await Product.countDocuments(query); - - res.send({ - products, - totalProducts, - currentOffset: offset, - limit: limit, - }); + const products = await prisma.product.findMany(); + res.send(products); }) ); @@ -75,7 +35,12 @@ app.get( app.get( "/products/:id", asyncHandler(async (req, res) => { - const product = await Product.findById(req.params.id).select("-updatedAt"); + const { id } = req.params; + const product = await prisma.product.findUnique({ + where: { + id, + }, + }); if (!product) { res.status(404).send({ message: "존재하지 않는 상품입니다." }); return; diff --git a/http/products.http b/http/products.http new file mode 100644 index 0000000..2914f0c --- /dev/null +++ b/http/products.http @@ -0,0 +1,52 @@ +GET http://localhost:3000/products + +### + +GET http://localhost:3000/products?offset=0&limit=0&keyword=불곰 + +### + +GET http://localhost:3000/products?keyword=판다 + +### + +GET http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de + +### + +POST http://localhost:3000/products +Content-Type: application/json + +{ + "name": "판다랑 불곰 교환원해요", + "description": "세종시청에서 교환원합니다.", + "price": 20000, + "tags": ["판다", "불곰"], + "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"], + "ownerId":1 +} + +### + +PATCH http://localhost:3000/products/664d393f71f073e051e9aaca +Content-Type: application/json + +{ + "name":"판다 안팔려서 안판다", + "description":"안판다고 했지만 사실은 판다", + "price":7000, + "tags":["판다","안판다"] +} + + +### + +DELETE http://localhost:3000/products/664d393f71f073e051e9aaca + +### +PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/like + +### + +PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/unlike + diff --git a/package-lock.json b/package-lock.json index 0f2104d..2db532a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,34 +5,73 @@ "packages": { "": { "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "mongoose": "^8.4.0" + "@prisma/client": "^5.4.2", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "is-email": "^1.0.2", + "is-uuid": "^1.0.2", + "prisma": "^5.4.2", + "superstruct": "^1.0.3" }, "devDependencies": { - "nodemon": "^3.1.0" + "nodemon": "^3.0.1" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.7.tgz", - "integrity": "sha512-dCHW/oEX0KJ4NjDULBo3JiOaK5+6axtpBbS+ao2ZInoAL9/YRQLhXzSNAFz7hP4nzLkIqsfYAK/PDE3+XHny0Q==", + "node_modules/@prisma/client": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.14.0.tgz", + "integrity": "sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.14.0.tgz", + "integrity": "sha512-iq56qBZuFfX3fCxoxT8gBX33lQzomBU0qIUaEj1RebsKVz1ob/BVH1XSBwwwvRVtZEV1b7Fxx2eVu34Ge/mg3w==" + }, + "node_modules/@prisma/engines": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.14.0.tgz", + "integrity": "sha512-lgxkKZ6IEygVcw6IZZUlPIfLQ9hjSYAtHjZ5r64sCLDgVzsPFCi2XBBJgzPMkOQ5RHzUD4E/dVdpn9+ez8tk1A==", + "hasInstallScript": true, "dependencies": { - "sparse-bitfield": "^3.0.3" + "@prisma/debug": "5.14.0", + "@prisma/engines-version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48", + "@prisma/fetch-engine": "5.14.0", + "@prisma/get-platform": "5.14.0" } }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + "node_modules/@prisma/engines-version": { + "version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48.tgz", + "integrity": "sha512-ip6pNkRo1UxWv+6toxNcYvItNYaqQjXdFNGJ+Nuk2eYtRoEdoF13wxo7/jsClJFFenMPVNVqXQDV0oveXnR1cA==" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.14.0.tgz", + "integrity": "sha512-VrheA9y9DMURK5vu8OJoOgQpxOhas3qF0IBHJ8G/0X44k82kc8E0w98HCn2nhnbOOMwbWsJWXfLC2/F8n5u0gQ==", + "dependencies": { + "@prisma/debug": "5.14.0", + "@prisma/engines-version": "5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48", + "@prisma/get-platform": "5.14.0" + } }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "node_modules/@prisma/get-platform": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.14.0.tgz", + "integrity": "sha512-/yAyBvcEjRv41ynZrhdrPtHgk47xLRRq/o5eWGcUpBJ1YrUZTYB8EoPiopnP7iQrMATK8stXQdPOoVlrzuTQZw==", "dependencies": { - "@types/webidl-conversions": "*" + "@prisma/debug": "5.14.0" } }, "node_modules/accepts": { @@ -141,14 +180,6 @@ "node": ">=8" } }, - "node_modules/bson": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.7.0.tgz", - "integrity": "sha512-w2IquM5mYzYZv6rs3uN2DZTOBe2a0zXLj53TGDqwF4l6Sz/XsISrisXOJihArF9+BZ6Cq/GjVht7Sjfmri7ytQ==", - "engines": { - "node": ">=16.20.1" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -237,22 +268,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -268,7 +288,8 @@ "node_modules/debug/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/define-data-property": { "version": "1.1.4", @@ -644,6 +665,11 @@ "node": ">=8" } }, + "node_modules/is-email": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-email/-/is-email-1.0.2.tgz", + "integrity": "sha512-UojUgD2EhDTBQ2SGKwrK9edce5phRzgLsP+V5+Uu2Swi+uvjVXgH3zduM3HhT9iaC/9Kq19/TYUbP0jPoi6ioA==" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -674,13 +700,10 @@ "node": ">=0.12.0" } }, - "node_modules/kareem": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", - "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", - "engines": { - "node": ">=12.0.0" - } + "node_modules/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" }, "node_modules/media-typer": { "version": "0.3.0", @@ -690,11 +713,6 @@ "node": ">= 0.6" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" - }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -750,100 +768,6 @@ "node": "*" } }, - "node_modules/mongodb": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.6.2.tgz", - "integrity": "sha512-ZF9Ugo2JCG/GfR7DEb4ypfyJJyiKbg5qBYKRintebj8+DNS33CyGMkWbrS9lara+u+h+yEOGSRiLhFO/g1s1aw==", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.5", - "bson": "^6.7.0", - "mongodb-connection-string-url": "^3.0.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", - "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^13.0.0" - } - }, - "node_modules/mongoose": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.4.0.tgz", - "integrity": "sha512-fgqRMwVEP1qgRYfh+tUe2YBBFnPO35FIg2lfFH+w9IhRGg1/ataWGIqvf/MjwM29cZ60D5vSnqtN2b8Qp0sOZA==", - "dependencies": { - "bson": "^6.7.0", - "kareem": "2.6.3", - "mongodb": "6.6.2", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -894,14 +818,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -946,6 +862,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prisma": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.14.0.tgz", + "integrity": "sha512-gCNZco7y5XtjrnQYeDJTiVZmT/ncqCr5RY1/Cf8X2wgLRmyh9ayPAGBNziI4qEE4S6SxCH5omQLVo9lmURaJ/Q==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.14.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -964,14 +895,6 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -1144,11 +1067,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1161,14 +1079,6 @@ "node": ">=10" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1177,6 +1087,14 @@ "node": ">= 0.8" } }, + "node_modules/superstruct": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.4.tgz", + "integrity": "sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1218,17 +1136,6 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tr46": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", - "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", - "dependencies": { - "punycode": "^2.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1270,26 +1177,6 @@ "engines": { "node": ">= 0.8" } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", - "dependencies": { - "tr46": "^4.1.1", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=16" - } } } } diff --git a/package.json b/package.json index 03de25d..7d7a89b 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,19 @@ { "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.19.2", - "mongoose": "^8.4.0" + "@prisma/client": "^5.4.2", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "is-email": "^1.0.2", + "is-uuid": "^1.0.2", + "prisma": "^5.4.2", + "superstruct": "^1.0.3" }, "devDependencies": { - "nodemon": "^3.1.0" + "nodemon": "^3.0.1" }, "type": "module", "scripts": { "dev": "nodemon app.js", - "start": "node app.js", - "seed": "node data/seed.js" + "start": "node app.js" } } diff --git a/prisma/migrations/20240522062619_init/migration.sql b/prisma/migrations/20240522062619_init/migration.sql new file mode 100644 index 0000000..6a86190 --- /dev/null +++ b/prisma/migrations/20240522062619_init/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "price" DOUBLE PRECISION NOT NULL, + "tags" TEXT[], + "images" TEXT[], + "favoriteCount" INTEGER NOT NULL DEFAULT 0, + "isFavorite" BOOLEAN NOT NULL DEFAULT false, + "ownerId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /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 (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..03d286e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,28 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Product { + id String @id @default(uuid()) + name String + description String + price Float + tags String[] + images String[] + favoriteCount Int @default(0) + isFavorite Boolean @default(false) + ownerId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} From 77a4ec5782e2713807c82b9b8be51b8f55fa5064 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 15:36:10 +0900 Subject: [PATCH 09/36] =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=93=B1=EB=A1=9D,?= =?UTF-8?q?=20=EC=83=81=ED=92=88=20=EC=88=98=EC=A0=95,=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 35 +++++++++++++++++------------------ http/products.http | 4 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app.js b/app.js index ebff140..7bfa0d3 100644 --- a/app.js +++ b/app.js @@ -54,8 +54,10 @@ app.get( app.post( "/products", asyncHandler(async (req, res) => { - const newProduct = await Product.create(req.body); - res.status(201).send(newProduct); + const product = await prisma.product.create({ + data: req.body, + }); + res.status(201).send(product); }) ); @@ -63,25 +65,17 @@ app.post( app.patch( "/products/:id", asyncHandler(async (req, res) => { - const product = await Product.findById(req.params.id); - + const { id } = req.params; + const product = await prisma.product.update({ + where: { + id, + }, + data: req.body, + }); if (!product) { res.status(404).send({ message: "존재하지 않는 상품입니다." }); return; } - const disallowedFields = { - favoriteCount: true, - isFavorite: true, - ownerId: true, - }; - - Object.keys(req.body).forEach((key) => { - if (!disallowedFields[key]) { - product[key] = req.body[key]; - } - }); - - await product.save(); res.send(product); }) @@ -91,7 +85,12 @@ app.patch( app.delete( "/products/:id", asyncHandler(async (req, res) => { - const product = await Product.findByIdAndDelete(req.params.id); + const { id } = req.params; + const product = await prisma.product.delete({ + where: { + id, + }, + }); if (!product) { res.status(404).send({ message: "존재하지 않는 상품입니다." }); diff --git a/http/products.http b/http/products.http index 2914f0c..c56456d 100644 --- a/http/products.http +++ b/http/products.http @@ -28,7 +28,7 @@ Content-Type: application/json ### -PATCH http://localhost:3000/products/664d393f71f073e051e9aaca +PATCH http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de Content-Type: application/json { @@ -41,7 +41,7 @@ Content-Type: application/json ### -DELETE http://localhost:3000/products/664d393f71f073e051e9aaca +DELETE http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de ### PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/like From 175c432b2878eddc59bea371d32a1b2275be0a2f Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 15:46:27 +0900 Subject: [PATCH 10/36] =?UTF-8?q?seed=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/seed.js | 12 ------------ package.json | 3 +++ {data => prisma}/mock.js | 0 prisma/seed.js | 22 ++++++++++++++++++++++ 4 files changed, 25 insertions(+), 12 deletions(-) delete mode 100644 data/seed.js rename {data => prisma}/mock.js (100%) create mode 100644 prisma/seed.js diff --git a/data/seed.js b/data/seed.js deleted file mode 100644 index b2fd350..0000000 --- a/data/seed.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as dotenv from "dotenv"; -import mongoose from "mongoose"; -import Product from "../models/product.js"; -import data from "./mock.js"; - -dotenv.config(); -mongoose.connect(process.env.DATABASE_URL); - -await Product.deleteMany({}); -await Product.insertMany(data); - -mongoose.connection.close(); diff --git a/package.json b/package.json index 7d7a89b..b61b442 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,8 @@ "scripts": { "dev": "nodemon app.js", "start": "node app.js" + }, + "prisma": { + "seed": "node prisma/seed.js" } } diff --git a/data/mock.js b/prisma/mock.js similarity index 100% rename from data/mock.js rename to prisma/mock.js diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..5e36868 --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,22 @@ +import { PrismaClient } from "@prisma/client"; +import data from "./mock.js"; +const prisma = new PrismaClient(); + +async function main() { + await prisma.product.deleteMany(); + + await prisma.product.createMany({ + data, + skipDuplicates: true, + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); From ca82e288df37d842203d82c100a78b175a5252b2 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 16:51:30 +0900 Subject: [PATCH 11/36] =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20http,=20mock=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 87 +++++++++++++++++++++++++++------------------- http/products.http | 12 +++---- models/product.js | 46 ------------------------ prisma/mock.js | 2 +- requests.http | 52 --------------------------- 5 files changed, 59 insertions(+), 140 deletions(-) delete mode 100644 models/product.js delete mode 100644 requests.http diff --git a/app.js b/app.js index 7bfa0d3..ea2f706 100644 --- a/app.js +++ b/app.js @@ -26,7 +26,36 @@ const asyncHandler = (handler) => { app.get( "/products", asyncHandler(async (req, res) => { - const products = await prisma.product.findMany(); + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 favorite, recent (기본값: recent) + * - keyword : 검색 키워드 + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + const products = await prisma.product.findMany({ + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + name: { + contains: keyword, + mode: "insensitive", + }, + }, + { + description: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); res.send(products); }) ); @@ -105,22 +134,17 @@ app.delete( app.patch( "/products/:id/like", asyncHandler(async (req, res) => { - const product = await Product.findById(req.params.id); - - if (!product) { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); - return; - } - - if (product.isFavorite) { - res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); - return; - } - - product.favoriteCount += 1; - product.isFavorite = true; - - await product.save(); + const product = await prisma.product.update({ + where: { + id: req.params.id, + }, + data: { + favoriteCount: { + increment: 1, + }, + isFavorite: true, + }, + }); res.send(product); }) @@ -130,24 +154,17 @@ app.patch( app.patch( "/products/:id/unlike", asyncHandler(async (req, res) => { - const product = await Product.findById(req.params.id); - - if (!product) { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); - return; - } - - if (!product.isFavorite) { - res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); - return; - } - - if (product.favoriteCount > 0) { - product.favoriteCount -= 1; - } - product.isFavorite = false; - - await product.save(); + const product = await prisma.product.update({ + where: { + id: req.params.id, + }, + data: { + favoriteCount: { + decrement: 1, + }, + isFavorite: false, + }, + }); res.send(product); }) diff --git a/http/products.http b/http/products.http index c56456d..ee530c6 100644 --- a/http/products.http +++ b/http/products.http @@ -2,7 +2,7 @@ GET http://localhost:3000/products ### -GET http://localhost:3000/products?offset=0&limit=0&keyword=불곰 +GET http://localhost:3000/products?offset=1&limit=2&keyword=판다&orderBy=favorite ### @@ -10,7 +10,7 @@ GET http://localhost:3000/products?keyword=판다 ### -GET http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de +GET http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 ### @@ -28,7 +28,7 @@ Content-Type: application/json ### -PATCH http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de +PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 Content-Type: application/json { @@ -41,12 +41,12 @@ Content-Type: application/json ### -DELETE http://localhost:3000/products/8edc68a8-8361-4411-bb9f-dcb1a4c3d0de +DELETE http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 ### -PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/like +PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/like ### -PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/unlike +PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/unlike diff --git a/models/product.js b/models/product.js deleted file mode 100644 index dae54d8..0000000 --- a/models/product.js +++ /dev/null @@ -1,46 +0,0 @@ -import mongoose from "mongoose"; - -const ProductSchema = new mongoose.Schema( - { - name: { - type: String, - required: true, - }, - description: { - type: String, - }, - price: { - type: Number, - required: true, - default: 0, - }, - tags: { - type: [String], - required: true, - }, - images: { - type: [String], - required: true, - }, - favoriteCount: { - type: Number, - default: 0, - }, - isFavorite: { - type: Boolean, - required: true, - default: false, - }, - ownerId: { - type: Number, - required: true, - }, - }, - { - timestamps: true, - } -); - -const Product = mongoose.model("Product", ProductSchema); - -export default Product; diff --git a/prisma/mock.js b/prisma/mock.js index be18f37..b9d8c76 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -1,6 +1,6 @@ const data = [ { - favoriteCount: 0, + favoriteCount: 7, ownerId: 1, images: ["https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg"], tags: ["판다인형", "인형", "판다"], diff --git a/requests.http b/requests.http deleted file mode 100644 index c2aae31..0000000 --- a/requests.http +++ /dev/null @@ -1,52 +0,0 @@ -GET http://localhost:3000/products - -### - -GET http://localhost:3000/products?offset=0&limit=0&keyword=불곰 - -### - -GET http://localhost:3000/products?keyword=판다 - -### - -GET http://localhost:3000/products/664d393f71f073e051e9aaca - -### - -POST http://localhost:3000/products -Content-Type: application/json - -{ - "name": "판다랑 불곰 교환원해요", - "description": "세종시청에서 교환원합니다.", - "price": 20000, - "tags": ["판다", "불곰"], - "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"], - "ownerId":1 -} - -### - -PATCH http://localhost:3000/products/664d393f71f073e051e9aaca -Content-Type: application/json - -{ - "name":"판다 안팔려서 안판다", - "description":"안판다고 했지만 사실은 판다", - "price":7000, - "tags":["판다","안판다"] -} - - -### - -DELETE http://localhost:3000/products/664d393f71f073e051e9aaca - -### -PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/like - -### - -PATCH http://localhost:3000/products/664d3eb1c4d678fe7647c427/unlike - From 8000c77d46f42d1fe0eefa56a5129e38ae5318bd Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 22 May 2024 17:21:49 +0900 Subject: [PATCH 12/36] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20-=20=EC=A4=91=EA=B3=A0=EB=A7=88=EC=BC=93=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 79 +++++++++++++------ http/products.http | 2 + .../20240522094218_init_article/migration.sql | 14 ++++ prisma/mockArticle.js | 12 +++ prisma/schema.prisma | 12 +++ prisma/seed.js | 8 ++ structs.js | 12 +++ 7 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20240522094218_init_article/migration.sql create mode 100644 prisma/mockArticle.js create mode 100644 structs.js diff --git a/app.js b/app.js index ea2f706..3340c31 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,7 @@ -import { PrismaClient } from "@prisma/client"; +import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; +import { assert } from "superstruct"; +import { CreateProduct, PatchProduct } from "./structs.js"; const prisma = new PrismaClient(); const app = express(); @@ -11,9 +13,9 @@ const asyncHandler = (handler) => { try { await handler(req, res); } catch (e) { - if (e.name === "ValidationError") { + if (e.name === "StructError" || e instanceof Prisma.PrismaClientValidationError) { res.status(400).send({ message: e.message }); - } else if (e.name === "CastError") { + } else if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { res.status(404).send({ message: "존재하지 않는 상품입니다." }); } else { res.status(500).send({ message: "서버 에러입니다." }); @@ -65,15 +67,11 @@ app.get( "/products/:id", asyncHandler(async (req, res) => { const { id } = req.params; - const product = await prisma.product.findUnique({ + const product = await prisma.product.findUniqueOrThrow({ where: { id, }, }); - if (!product) { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); - return; - } res.send(product); }) @@ -83,6 +81,7 @@ app.get( app.post( "/products", asyncHandler(async (req, res) => { + assert(req.query, CreateProduct); const product = await prisma.product.create({ data: req.body, }); @@ -94,6 +93,7 @@ app.post( app.patch( "/products/:id", asyncHandler(async (req, res) => { + assert(req.body, PatchProduct); const { id } = req.params; const product = await prisma.product.update({ where: { @@ -101,10 +101,6 @@ app.patch( }, data: req.body, }); - if (!product) { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); - return; - } res.send(product); }) @@ -115,17 +111,12 @@ app.delete( "/products/:id", asyncHandler(async (req, res) => { const { id } = req.params; - const product = await prisma.product.delete({ + await prisma.product.delete({ where: { id, }, }); - if (!product) { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); - return; - } - res.sendStatus(204); }) ); @@ -134,7 +125,18 @@ app.delete( app.patch( "/products/:id/like", asyncHandler(async (req, res) => { - const product = await prisma.product.update({ + const product = await prisma.product.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (product.isFavorite) { + res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); + return; + } + + const updatedProduct = await prisma.product.update({ where: { id: req.params.id, }, @@ -146,7 +148,7 @@ app.patch( }, }); - res.send(product); + res.send(updatedProduct); }) ); @@ -154,7 +156,18 @@ app.patch( app.patch( "/products/:id/unlike", asyncHandler(async (req, res) => { - const product = await prisma.product.update({ + const product = await prisma.product.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (!product.isFavorite) { + res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); + return; + } + + const updatedProduct = await prisma.product.update({ where: { id: req.params.id, }, @@ -166,7 +179,29 @@ app.patch( }, }); - res.send(product); + res.send(updatedProduct); + }) +); + +// 게시글 조회 +app.get( + "/articles", + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 favorite, recent (기본값: recent) + * - keyword : 검색 키워드 + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + const articles = await prisma.article.findMany({ + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + }); + res.send(articles); }) ); diff --git a/http/products.http b/http/products.http index ee530c6..6700304 100644 --- a/http/products.http +++ b/http/products.http @@ -50,3 +50,5 @@ PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/like PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/unlike +### +GET http://localhost:3000/articles \ No newline at end of file diff --git a/prisma/migrations/20240522094218_init_article/migration.sql b/prisma/migrations/20240522094218_init_article/migration.sql new file mode 100644 index 0000000..78aea67 --- /dev/null +++ b/prisma/migrations/20240522094218_init_article/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Article" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "imageUrl" TEXT, + "likeCount" INTEGER NOT NULL DEFAULT 0, + "isLiked" BOOLEAN NOT NULL DEFAULT false, + "writer" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Article_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/mockArticle.js b/prisma/mockArticle.js new file mode 100644 index 0000000..2c61221 --- /dev/null +++ b/prisma/mockArticle.js @@ -0,0 +1,12 @@ +const data = [ + { + title: "판다인형 구매 후기", + content: "판다인형 구매 후기입니다.", + imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", + likeCount: 7, + isLiked: false, + writer: "판다인형 수집가", + }, +]; + +export default data; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03d286e..0f30792 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,3 +26,15 @@ model Product { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Article { + id String @id @default(uuid()) + title String + content String + imageUrl String? + likeCount Int @default(0) + isLiked Boolean @default(false) + writer String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/prisma/seed.js b/prisma/seed.js index 5e36868..23c5772 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,5 +1,6 @@ import { PrismaClient } from "@prisma/client"; import data from "./mock.js"; +import article from "./mockArticle.js"; const prisma = new PrismaClient(); async function main() { @@ -9,6 +10,13 @@ async function main() { data, skipDuplicates: true, }); + + await prisma.article.deleteMany(); + + await prisma.article.createMany({ + data: article, + skipDuplicates: true, + }); } main() diff --git a/structs.js b/structs.js new file mode 100644 index 0000000..4fa99f5 --- /dev/null +++ b/structs.js @@ -0,0 +1,12 @@ +import * as s from "superstruct"; + +export const CreateProduct = s.object({ + ownerId: s.number(), + images: s.array(s.string()), + tags: s.array(s.string()), + price: s.refine(s.number(), (price) => price > 0 && price < 1000000000), + description: s.string(), + name: s.string(), +}); + +export const PatchProduct = s.partial(CreateProduct); From 16a5a58ffaa40a0df1170843c68c34e4c8caa82a Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 08:08:40 +0900 Subject: [PATCH 13/36] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EA=B2=8C=EC=8B=9C=EA=B8=80?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 66 +++++++++++++++++++++++++++++++++++++++---- http/articles.http | 8 ++++++ http/products.http | 19 +++++++------ prisma/mockArticle.js | 16 +++++++++++ 4 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 http/articles.http diff --git a/app.js b/app.js index 3340c31..a14b669 100644 --- a/app.js +++ b/app.js @@ -72,7 +72,6 @@ app.get( id, }, }); - res.send(product); }) ); @@ -183,7 +182,7 @@ app.patch( }) ); -// 게시글 조회 +// 게시글 목록 조회 app.get( "/articles", asyncHandler(async (req, res) => { @@ -191,17 +190,72 @@ app.get( * 쿼리 파라미터 * - offset : 가져올 데이터의 시작 지점 * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 favorite, recent (기본값: recent) - * - keyword : 검색 키워드 + * - orderBy : 정렬 기준 like, recent (기본값: recent) */ const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; const articles = await prisma.article.findMany({ + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + writer: true, + }, orderBy: order, skip: parseInt(offset), take: parseInt(limit), + where: { + OR: [ + { + title: { + contains: keyword, + mode: "insensitive", + }, + }, + { + content: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); + // 좋아요가 많은 상위 4개의 글 조회 + const bestArticles = await prisma.article.findMany({ + orderBy: { + likeCount: "desc", + }, + take: 4, + }); + + res.send({ articles, bestArticles }); + }) +); + +// 게시글 상세 조회 +app.get( + "/articles/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + const article = await prisma.article.findUniqueOrThrow({ + where: { + id, + }, + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + likeCount: true, + isLiked: true, + writer: true, + }, }); - res.send(articles); + res.send(article); }) ); diff --git a/http/articles.http b/http/articles.http new file mode 100644 index 0000000..78651d2 --- /dev/null +++ b/http/articles.http @@ -0,0 +1,8 @@ +# 게시글 목록 조회 +GET http://localhost:3000/articles?&limit=10&&orderBy=like + +### +# 게시글 상세 조회 + +GET http://localhost:3000/articles/b66f0adf-0bc1-4bf7-a8bb-6fc8141fa56d + diff --git a/http/products.http b/http/products.http index 6700304..610fcb3 100644 --- a/http/products.http +++ b/http/products.http @@ -1,19 +1,22 @@ + +# 상품 조회 쿼리 x GET http://localhost:3000/products ### +# 상품 조회 쿼리 o GET http://localhost:3000/products?offset=1&limit=2&keyword=판다&orderBy=favorite ### - +# 상품 조회 검색어 테스트 GET http://localhost:3000/products?keyword=판다 ### - +# 상품 상세 조회 GET http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 ### - +# 상품 등록 POST http://localhost:3000/products Content-Type: application/json @@ -27,7 +30,7 @@ Content-Type: application/json } ### - +# 상품 수정 PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 Content-Type: application/json @@ -38,17 +41,15 @@ Content-Type: application/json "tags":["판다","안판다"] } - ### - +# 상품 삭제 DELETE http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 ### +# 상품 좋아요 PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/like ### - +# 상품 좋아요 취소 PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/unlike -### -GET http://localhost:3000/articles \ No newline at end of file diff --git a/prisma/mockArticle.js b/prisma/mockArticle.js index 2c61221..54630c3 100644 --- a/prisma/mockArticle.js +++ b/prisma/mockArticle.js @@ -7,6 +7,22 @@ const data = [ isLiked: false, writer: "판다인형 수집가", }, + { + title: "판다인형 판매 후기", + content: "판다인형 판매 후기입니다.", + imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", + likeCount: 2, + isLiked: true, + writer: "판다인형 중개인", + }, + { + title: "불곰인형 구하는 곳 아시는분", + content: "불곰인형 구하는 곳 아시는분 계신가요?", + imageUrl: "https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg", + likeCount: 3, + isLiked: false, + writer: "불곰인형 수집가", + }, ]; export default data; From 30b29804f8c67c3ad94dc35133084fb4bf5ec25e Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 08:28:47 +0900 Subject: [PATCH 14/36] =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20api=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 5 ++--- structs.js | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index a14b669..7fd7f10 100644 --- a/app.js +++ b/app.js @@ -2,10 +2,9 @@ import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; import { assert } from "superstruct"; import { CreateProduct, PatchProduct } from "./structs.js"; -const prisma = new PrismaClient(); +const prisma = new PrismaClient(); const app = express(); - app.use(express.json()); const asyncHandler = (handler) => { @@ -80,7 +79,7 @@ app.get( app.post( "/products", asyncHandler(async (req, res) => { - assert(req.query, CreateProduct); + assert(req.body, CreateProduct); const product = await prisma.product.create({ data: req.body, }); diff --git a/structs.js b/structs.js index 4fa99f5..ab716f8 100644 --- a/structs.js +++ b/structs.js @@ -1,10 +1,12 @@ import * as s from "superstruct"; +const PositivePrice = s.refine(s.number(), "PositivePrice", (value) => value > 0 && value < 1000000000); + export const CreateProduct = s.object({ ownerId: s.number(), images: s.array(s.string()), tags: s.array(s.string()), - price: s.refine(s.number(), (price) => price > 0 && price < 1000000000), + price: PositivePrice, description: s.string(), name: s.string(), }); From ee6daef67f45c7a57534dffae7a770e907a08145 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 08:46:08 +0900 Subject: [PATCH 15/36] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D,=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 48 ++++++++++++++++++++++++++++++++++++++++++++-- http/articles.http | 25 ++++++++++++++++++++++++ structs.js | 7 +++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 7fd7f10..f9b6758 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; import { assert } from "superstruct"; -import { CreateProduct, PatchProduct } from "./structs.js"; +import { CreateArticle, CreateProduct, PatchArticle, PatchProduct } from "./structs.js"; const prisma = new PrismaClient(); const app = express(); @@ -15,7 +15,7 @@ const asyncHandler = (handler) => { if (e.name === "StructError" || e instanceof Prisma.PrismaClientValidationError) { res.status(400).send({ message: e.message }); } else if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { - res.status(404).send({ message: "존재하지 않는 상품입니다." }); + res.status(404).send({ message: "존재하지 않는 게시글입니다." }); } else { res.status(500).send({ message: "서버 에러입니다." }); } @@ -258,4 +258,48 @@ app.get( }) ); +// 게시글 등록 +app.post( + "/articles", + asyncHandler(async (req, res) => { + assert(req.body, CreateArticle); + const article = await prisma.article.create({ + data: req.body, + }); + res.status(201).send(article); + }) +); + +// 게시글 수정 +app.patch( + "/articles/:id", + asyncHandler(async (req, res) => { + assert(req.body, PatchArticle); + const { id } = req.params; + const article = await prisma.article.update({ + where: { + id, + }, + data: req.body, + }); + + res.send(article); + }) +); + +// 게시글 삭제 +app.delete( + "/articles/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + await prisma.article.delete({ + where: { + id, + }, + }); + + res.sendStatus(204); + }) +); + app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/http/articles.http b/http/articles.http index 78651d2..0b128a4 100644 --- a/http/articles.http +++ b/http/articles.http @@ -6,3 +6,28 @@ GET http://localhost:3000/articles?&limit=10&&orderBy=like GET http://localhost:3000/articles/b66f0adf-0bc1-4bf7-a8bb-6fc8141fa56d +### +# 게시글 등록 +POST http://localhost:3000/articles +Content-Type: application/json + +{ + "title": "제가 아끼는 티모 인형입니다", + "content": "버섯 농사 짓는 모습이 너무 깜찍하지않나요?", + "imageUrl": "https://cdn.011st.com/11dims/resize/600x600/quality/75/11src/product/5575072075/B.jpg?51000000", + "writer": "티모매니아" +} + +### +# 게시글 수정 +PATCH http://localhost:3000/articles/7a640d97-ba35-4bae-bf9e-b6f30bf0ebb0 +Content-Type: application/json + +{ + "title":"판다 대박이네요", + "content":"대나무를 잘 먹네요 ㄷㄷ" +} + +### +# 게시글 삭제 +DELETE http://localhost:3000/articles/7a640d97-ba35-4bae-bf9e-b6f30bf0ebb0 diff --git a/structs.js b/structs.js index ab716f8..b550f0e 100644 --- a/structs.js +++ b/structs.js @@ -12,3 +12,10 @@ export const CreateProduct = s.object({ }); export const PatchProduct = s.partial(CreateProduct); + +export const CreateArticle = s.object({ + title: s.string(), + content: s.string(), +}); + +export const PatchArticle = s.partial(CreateArticle); From d632f32471f1275a1a96ac1c90f527caf8223700 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 08:56:24 +0900 Subject: [PATCH 16/36] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++ http/articles.http | 9 +++++++ 2 files changed, 71 insertions(+) diff --git a/app.js b/app.js index f9b6758..8f98a0f 100644 --- a/app.js +++ b/app.js @@ -302,4 +302,66 @@ app.delete( }) ); +// 게시글 좋아요 +app.patch( + "/articles/:id/like", + asyncHandler(async (req, res) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (article.isLiked) { + res.status(400).send({ message: "이미 좋아요 처리된 게시글입니다." }); + return; + } + + const updatedArticle = await prisma.article.update({ + where: { + id: req.params.id, + }, + data: { + likeCount: { + increment: 1, + }, + isLiked: true, + }, + }); + + res.send(updatedArticle); + }) +); + +// 게시글 좋아요 취소 +app.patch( + "/articles/:id/unlike", + asyncHandler(async (req, res) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (!article.isLiked) { + res.status(400).send({ message: "아직 좋아요 처리되지 않은 게시글입니다." }); + return; + } + + const updatedArticle = await prisma.article.update({ + where: { + id: req.params.id, + }, + data: { + likeCount: { + decrement: 1, + }, + isLiked: false, + }, + }); + + res.send(updatedArticle); + }) +); + app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/http/articles.http b/http/articles.http index 0b128a4..37bcd07 100644 --- a/http/articles.http +++ b/http/articles.http @@ -31,3 +31,12 @@ Content-Type: application/json ### # 게시글 삭제 DELETE http://localhost:3000/articles/7a640d97-ba35-4bae-bf9e-b6f30bf0ebb0 + +### +# 게시글 좋아요 +PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/like + +### +# 게시글 좋아요 취소 +PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/unlike + From 883250b00207cb1acde3b608509c3540a0d26402 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 09:38:53 +0900 Subject: [PATCH 17/36] =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=AA=A8=EB=8D=B8,?= =?UTF-8?q?=20seed,=20mock=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20240523002157_init_comment/migration.sql | 24 ++++++++ prisma/mock.js | 55 ++++++++++++++++++- prisma/mockArticle.js | 28 ---------- prisma/schema.prisma | 33 ++++++++--- prisma/seed.js | 13 +++-- 5 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 prisma/migrations/20240523002157_init_comment/migration.sql delete mode 100644 prisma/mockArticle.js diff --git a/prisma/migrations/20240523002157_init_comment/migration.sql b/prisma/migrations/20240523002157_init_comment/migration.sql new file mode 100644 index 0000000..645652d --- /dev/null +++ b/prisma/migrations/20240523002157_init_comment/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "writer" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "productId" TEXT, + "articleId" TEXT, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Comment_productId_idx" ON "Comment"("productId"); + +-- CreateIndex +CREATE INDEX "Comment_articleId_idx" ON "Comment"("articleId"); + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/mock.js b/prisma/mock.js index b9d8c76..43c170d 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -1,5 +1,6 @@ -const data = [ +export const PRODUCTS = [ { + id: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", favoriteCount: 7, ownerId: 1, images: ["https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg"], @@ -9,6 +10,7 @@ const data = [ name: "판다인형", }, { + id: "d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9", favoriteCount: 2, ownerId: 2, images: [ @@ -21,4 +23,53 @@ const data = [ }, ]; -export default data; +export const ARTICLES = [ + { + id: "2c027764-d7ef-4a94-8399-f15ffbf8f4da", + title: "판다인형 구매 후기", + content: "판다인형 구매 후기입니다.", + imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", + likeCount: 7, + isLiked: false, + writer: "판다인형 수집가", + }, + { + id: "7c8b9d2e-5d45-4c9f-9b4b-7626f3c9c9a9", + title: "판다인형 판매 후기", + content: "판다인형 판매 후기입니다.", + imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", + likeCount: 2, + isLiked: true, + writer: "판다인형 중개인", + }, + { + id: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + title: "불곰인형 구하는 곳 아시는분", + content: "불곰인형 구하는 곳 아시는분 계신가요?", + imageUrl: "https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg", + likeCount: 3, + isLiked: false, + writer: "불곰인형 수집가", + }, +]; + +export const COMMENTS = [ + { + id: "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p", + content: "판다인형 너무 귀여워요!", + writer: "판다인형 수집가", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + id: "2b3c4d5e-6f7g-8h9i-0j1k-2l3m4n5o6p7q", + content: "판다인형 너무 귀여워요!", + writer: "판다인형 중개인", + articleId: "2c027764-d7ef-4a94-8399-f15ffbf8f4da", + }, + { + id: "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", + content: "불곰인형 너무 귀여워요!", + writer: "불곰인형 수집가", + articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + }, +]; diff --git a/prisma/mockArticle.js b/prisma/mockArticle.js deleted file mode 100644 index 54630c3..0000000 --- a/prisma/mockArticle.js +++ /dev/null @@ -1,28 +0,0 @@ -const data = [ - { - title: "판다인형 구매 후기", - content: "판다인형 구매 후기입니다.", - imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", - likeCount: 7, - isLiked: false, - writer: "판다인형 수집가", - }, - { - title: "판다인형 판매 후기", - content: "판다인형 판매 후기입니다.", - imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", - likeCount: 2, - isLiked: true, - writer: "판다인형 중개인", - }, - { - title: "불곰인형 구하는 곳 아시는분", - content: "불곰인형 구하는 곳 아시는분 계신가요?", - imageUrl: "https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg", - likeCount: 3, - isLiked: false, - writer: "불곰인형 수집가", - }, -]; - -export default data; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f30792..4b24178 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,27 +14,44 @@ datasource db { } model Product { - id String @id @default(uuid()) + id String @id @default(uuid()) name String description String price Float tags String[] images String[] - favoriteCount Int @default(0) - isFavorite Boolean @default(false) + favoriteCount Int @default(0) + isFavorite Boolean @default(false) ownerId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comments Comment[] } model Article { - id String @id @default(uuid()) + id String @id @default(uuid()) title String content String imageUrl String? - likeCount Int @default(0) - isLiked Boolean @default(false) + likeCount Int @default(0) + isLiked Boolean @default(false) + writer String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + comments Comment[] +} + +model Comment { + id String @id @default(uuid()) + content String writer String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + productId String? + article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull) + articleId String? + + @@index([productId]) + @@index([articleId]) } diff --git a/prisma/seed.js b/prisma/seed.js index 23c5772..92f7f42 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,20 +1,25 @@ import { PrismaClient } from "@prisma/client"; -import data from "./mock.js"; -import article from "./mockArticle.js"; +import { ARTICLES, COMMENTS, PRODUCTS } from "./mock.js"; const prisma = new PrismaClient(); async function main() { await prisma.product.deleteMany(); await prisma.product.createMany({ - data, + data: PRODUCTS, skipDuplicates: true, }); await prisma.article.deleteMany(); await prisma.article.createMany({ - data: article, + data: ARTICLES, + skipDuplicates: true, + }); + await prisma.comment.deleteMany(); + + await prisma.comment.createMany({ + data: COMMENTS, skipDuplicates: true, }); } From fe528aa9633fb92df3a301684e4d732c8f0c3e95 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 10:06:42 +0900 Subject: [PATCH 18/36] =?UTF-8?q?=EC=A4=91=EA=B3=A0=EB=A7=88=EC=BC=93=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D,=20=EC=88=98=EC=A0=95,=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++ http/products.http | 30 ++++++++++++++++ prisma/mock.js | 61 ++++++++++++++++++++++++++++++-- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 8f98a0f..0fc92e2 100644 --- a/app.js +++ b/app.js @@ -364,4 +364,90 @@ app.patch( }) ); +// 중고마켓 댓글 목록 조회 +app.get( + "/products/:id/comments", + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + console.log(cursor); + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + productId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) +); + +// 중고마켓 댓글 등록 +app.post( + "/products/:id/comments", + asyncHandler(async (req, res) => { + const { id, cursor } = req.params; + + const comment = await prisma.comment.create({ + data: { + content: req.body.content, + writer: req.body.writer, + productId: id, + }, + }); + res.status(201).send(comment); + }) +); + +// 중고마켓 댓글 수정 +app.patch( + "/products/:id/comments/:commentId", + asyncHandler(async (req, res) => { + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); + + res.send(comment); + }) +); + +// 중고마켓 댓글 삭제 +app.delete( + "/products/:id/comments/:commentId", + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); + + res.sendStatus(204); + }) +); + app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/http/products.http b/http/products.http index 610fcb3..36dcd96 100644 --- a/http/products.http +++ b/http/products.http @@ -53,3 +53,33 @@ PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/like # 상품 좋아요 취소 PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/unlike +### +# 상품 댓글 조회 +GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments + +### +# 상품 댓글 커서 조회 +GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments?cursor=c45c56cf-d7d4-4592-885c-abcb56661bb5 + +### +# 상품 댓글 등록 +POST http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments +Content-Type: application/json + +{ + "content":"판다가 너무 귀여워요", + "writer":"판다사랑나라사랑" +} + +### +# 상품 댓글 수정 +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/512b432b-7618-46ea-a500-579daacc0d02 +Content-Type: application/json + +{ + "content":"판다가 너무 귀여워요 수정" +} + +### +# 상품 댓글 삭제 +DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/512b432b-7618-46ea-a500-579daacc0d02 \ No newline at end of file diff --git a/prisma/mock.js b/prisma/mock.js index 43c170d..64ec75a 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -58,13 +58,68 @@ export const COMMENTS = [ id: "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p", content: "판다인형 너무 귀여워요!", writer: "판다인형 수집가", - productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + articleId: "2c027764-d7ef-4a94-8399-f15ffbf8f4da", }, { id: "2b3c4d5e-6f7g-8h9i-0j1k-2l3m4n5o6p7q", - content: "판다인형 너무 귀여워요!", + content: "판다인형 너무 귀여워요1", writer: "판다인형 중개인", - articleId: "2c027764-d7ef-4a94-8399-f15ffbf8f4da", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요2", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요3", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요4", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요5", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요6", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요7", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요8", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요9", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요10", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요11", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, + { + content: "판다인형 너무 귀여워요12", + writer: "판다인형 중개인", + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", }, { id: "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", From 2de35b2aac43e865b7c0f91ac4a8db8379a99384 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 11:39:52 +0900 Subject: [PATCH 19/36] =?UTF-8?q?=EC=9E=90=EC=9C=A0=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=ED=8C=90=20=EB=8C=93=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=20=EB=93=B1=EB=A1=9D,=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 96 +++++++++++++++++++++++++++++++++++++++++++--- http/articles.http | 35 ++++++++++++++++- structs.js | 7 ++++ 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 0fc92e2..1a02c69 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; import { assert } from "superstruct"; -import { CreateArticle, CreateProduct, PatchArticle, PatchProduct } from "./structs.js"; +import { CreateArticle, CreateComment, CreateProduct, PatchArticle, PatchComment, PatchProduct } from "./structs.js"; const prisma = new PrismaClient(); const app = express(); @@ -370,7 +370,6 @@ app.get( asyncHandler(async (req, res) => { const { id } = req.params; const { cursor } = req.query; - console.log(cursor); let queryOptions = { take: 10, orderBy: { @@ -406,12 +405,12 @@ app.get( app.post( "/products/:id/comments", asyncHandler(async (req, res) => { - const { id, cursor } = req.params; + assert(req.body, CreateComment); + const { id } = req.params; const comment = await prisma.comment.create({ data: { - content: req.body.content, - writer: req.body.writer, + ...req.body, productId: id, }, }); @@ -423,6 +422,7 @@ app.post( app.patch( "/products/:id/comments/:commentId", asyncHandler(async (req, res) => { + assert(req.body, PatchComment); const { commentId } = req.params; const comment = await prisma.comment.update({ where: { @@ -450,4 +450,90 @@ app.delete( }) ); +// 자유게시판 댓글 목록 조회 +app.get( + "/articles/:id/comments", + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + articleId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) +); + +// 자유게시판 댓글 등록 +app.post( + "/articles/:id/comments", + asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + const { id } = req.params; + + const comment = await prisma.comment.create({ + data: { + ...req.body, + articleId: id, + }, + }); + res.status(201).send(comment); + }) +); + +// 자유게시판 댓글 수정 +app.patch( + "/articles/:id/comments/:commentId", + asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); + + res.send(comment); + }) +); + +// 중고마켓 댓글 삭제 +app.delete( + "/articles/:id/comments/:commentId", + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); + + res.sendStatus(204); + }) +); + app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/http/articles.http b/http/articles.http index 37bcd07..8b518af 100644 --- a/http/articles.http +++ b/http/articles.http @@ -30,7 +30,7 @@ Content-Type: application/json ### # 게시글 삭제 -DELETE http://localhost:3000/articles/7a640d97-ba35-4bae-bf9e-b6f30bf0ebb0 +DELETE http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 좋아요 @@ -40,3 +40,36 @@ PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/like # 게시글 좋아요 취소 PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/unlike + + + +### +# 자유게시판 댓글 조회 +GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments + +### +# 자유게시판 댓글 커서 조회 +GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments?cursor=43d35ef2-42f0-43be-b85c-f9614195bb39 + +### +# 자유게시판 댓글 등록 +POST http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments +Content-Type: application/json + +{ + "content":"판다가 너무 귀여워요2", + "writer":"판다사랑나라사랑" +} + +### +# 자유게시판 댓글 수정 +PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments/3c94cbe4-bd70-4c32-969a-225af0dd1e20 +Content-Type: application/json + +{ + "content":"판다가 너무 귀여워요 수정" +} + +### +# 자유게시판 댓글 삭제 +DELETE http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments/3c94cbe4-bd70-4c32-969a-225af0dd1e20 \ No newline at end of file diff --git a/structs.js b/structs.js index b550f0e..1050ee2 100644 --- a/structs.js +++ b/structs.js @@ -19,3 +19,10 @@ export const CreateArticle = s.object({ }); export const PatchArticle = s.partial(CreateArticle); + +export const CreateComment = s.object({ + content: s.string(), + writer: s.string(), +}); + +export const PatchComment = s.partial(CreateComment); From 4d09d8c3d0a0e206749db3f30c31d6166c04d19a Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 14:48:32 +0900 Subject: [PATCH 20/36] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC,=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index 1a02c69..55feaa0 100644 --- a/app.js +++ b/app.js @@ -12,12 +12,19 @@ const asyncHandler = (handler) => { try { await handler(req, res); } catch (e) { - if (e.name === "StructError" || e instanceof Prisma.PrismaClientValidationError) { - res.status(400).send({ message: e.message }); + if (e.name === "StructError") { + const errors = e.failures().map((failure) => ({ + path: failure.path.join("."), + message: failure.message, + })); + res.status(400).json({ message: "유효성 검사 오류입니다.", errors }); + } else if (e instanceof Prisma.PrismaClientValidationError) { + res.status(400).json({ message: e.message }); } else if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { - res.status(404).send({ message: "존재하지 않는 게시글입니다." }); + res.status(404).json({ message: "존재하지 않는 게시글입니다." }); } else { - res.status(500).send({ message: "서버 에러입니다." }); + console.error(e); + res.status(500).json({ message: "서버 에러입니다." }); } } }; From 5e3948a9ce0f8e0c9e00585c360fd9aa8b03cb0d Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Thu, 23 May 2024 16:20:39 +0900 Subject: [PATCH 21/36] =?UTF-8?q?writer=20=ED=95=84=EB=93=9C=EB=A5=BC=20op?= =?UTF-8?q?tional=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/articles.http | 22 +++++++++---------- http/products.http | 16 +++++++------- .../migration.sql | 6 +++++ prisma/mock.js | 16 ++++++++++++++ prisma/schema.prisma | 6 ++--- structs.js | 4 +++- 6 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 prisma/migrations/20240523072342_change_writer_to_optional/migration.sql diff --git a/http/articles.http b/http/articles.http index 8b518af..ad5c723 100644 --- a/http/articles.http +++ b/http/articles.http @@ -4,7 +4,7 @@ GET http://localhost:3000/articles?&limit=10&&orderBy=like ### # 게시글 상세 조회 -GET http://localhost:3000/articles/b66f0adf-0bc1-4bf7-a8bb-6fc8141fa56d +GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 등록 @@ -14,13 +14,12 @@ Content-Type: application/json { "title": "제가 아끼는 티모 인형입니다", "content": "버섯 농사 짓는 모습이 너무 깜찍하지않나요?", - "imageUrl": "https://cdn.011st.com/11dims/resize/600x600/quality/75/11src/product/5575072075/B.jpg?51000000", - "writer": "티모매니아" + "imageUrl": "https://cdn.011st.com/11dims/resize/600x600/quality/75/11src/product/5575072075/B.jpg?51000000" } ### # 게시글 수정 -PATCH http://localhost:3000/articles/7a640d97-ba35-4bae-bf9e-b6f30bf0ebb0 +PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da Content-Type: application/json { @@ -34,22 +33,22 @@ DELETE http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 좋아요 -PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/like +PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/like ### # 게시글 좋아요 취소 -PATCH http://localhost:3000/articles/7757f3a4-0075-47e0-a9e5-b38500948b8e/unlike +PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/unlike ### # 자유게시판 댓글 조회 -GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments +GET http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments ### # 자유게시판 댓글 커서 조회 -GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments?cursor=43d35ef2-42f0-43be-b85c-f9614195bb39 +GET http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments?cursor=4f9cd26c-09fa-441c-997f-2fdec0fb443e ### # 자유게시판 댓글 등록 @@ -57,13 +56,12 @@ POST http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comment Content-Type: application/json { - "content":"판다가 너무 귀여워요2", - "writer":"판다사랑나라사랑" + "content":"판다가 너무 귀여워요2" } ### # 자유게시판 댓글 수정 -PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments/3c94cbe4-bd70-4c32-969a-225af0dd1e20 +PATCH http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p Content-Type: application/json { @@ -72,4 +70,4 @@ Content-Type: application/json ### # 자유게시판 댓글 삭제 -DELETE http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/comments/3c94cbe4-bd70-4c32-969a-225af0dd1e20 \ No newline at end of file +DELETE http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p \ No newline at end of file diff --git a/http/products.http b/http/products.http index 36dcd96..1126069 100644 --- a/http/products.http +++ b/http/products.http @@ -13,7 +13,7 @@ GET http://localhost:3000/products?keyword=판다 ### # 상품 상세 조회 -GET http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 +GET http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 ### # 상품 등록 @@ -31,7 +31,7 @@ Content-Type: application/json ### # 상품 수정 -PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 +PATCH http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 Content-Type: application/json { @@ -43,15 +43,15 @@ Content-Type: application/json ### # 상품 삭제 -DELETE http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7 +DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc ### # 상품 좋아요 -PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/like +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/like ### # 상품 좋아요 취소 -PATCH http://localhost:3000/products/69c4bd5b-1281-4d6e-99de-fc18feed6de7/unlike +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/unlike ### # 상품 댓글 조회 @@ -59,7 +59,7 @@ GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments ### # 상품 댓글 커서 조회 -GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments?cursor=c45c56cf-d7d4-4592-885c-abcb56661bb5 +GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments?cursor=f81aac65-fb44-4973-8d6d-3789c6ba39d7 ### # 상품 댓글 등록 @@ -73,7 +73,7 @@ Content-Type: application/json ### # 상품 댓글 수정 -PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/512b432b-7618-46ea-a500-579daacc0d02 +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d Content-Type: application/json { @@ -82,4 +82,4 @@ Content-Type: application/json ### # 상품 댓글 삭제 -DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/512b432b-7618-46ea-a500-579daacc0d02 \ No newline at end of file +DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d \ No newline at end of file diff --git a/prisma/migrations/20240523072342_change_writer_to_optional/migration.sql b/prisma/migrations/20240523072342_change_writer_to_optional/migration.sql new file mode 100644 index 0000000..c2d04be --- /dev/null +++ b/prisma/migrations/20240523072342_change_writer_to_optional/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Article" ALTER COLUMN "imageUrl" SET DEFAULT '', +ALTER COLUMN "writer" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Comment" ALTER COLUMN "writer" DROP NOT NULL; diff --git a/prisma/mock.js b/prisma/mock.js index 64ec75a..0b4e46e 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -127,4 +127,20 @@ export const COMMENTS = [ writer: "불곰인형 수집가", articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", }, + { + id: "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", + content: "불곰인형 너무 귀여워요1", + writer: "불곰인형 수집가", + articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + }, + { + content: "불곰인형 너무 귀여워요2", + writer: "불곰인형 수집가", + articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + }, + { + content: "불곰인형 너무 귀여워요3", + writer: "불곰인형 수집가", + articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + }, ]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b24178..56b0da4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,10 +32,10 @@ model Article { id String @id @default(uuid()) title String content String - imageUrl String? + imageUrl String? @default("") likeCount Int @default(0) isLiked Boolean @default(false) - writer String + writer String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -44,7 +44,7 @@ model Article { model Comment { id String @id @default(uuid()) content String - writer String + writer String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) diff --git a/structs.js b/structs.js index 1050ee2..74e0e00 100644 --- a/structs.js +++ b/structs.js @@ -16,13 +16,15 @@ export const PatchProduct = s.partial(CreateProduct); export const CreateArticle = s.object({ title: s.string(), content: s.string(), + imageUrl: s.optional(s.string()), + writer: s.optional(s.string()), }); export const PatchArticle = s.partial(CreateArticle); export const CreateComment = s.object({ content: s.string(), - writer: s.string(), + writer: s.optional(s.string()), }); export const PatchComment = s.partial(CreateComment); From a758889835369a4165a80381e09c81f97d9d8711 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 12:42:48 +0900 Subject: [PATCH 22/36] =?UTF-8?q?[#M10]=20feat=20:=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 33 ++++ package-lock.json | 153 ++++++++++++++++++ package.json | 1 + .../migration.sql | 23 +++ prisma/schema.prisma | 16 ++ test.html | 17 ++ uploads/image-1716781093102-30560313.png | Bin 0 -> 62686 bytes uploads/image-1716781277009-379093839.png | Bin 0 -> 62686 bytes 8 files changed, 243 insertions(+) create mode 100644 prisma/migrations/20240527032014_add_product_and_article_image_model/migration.sql create mode 100644 test.html create mode 100644 uploads/image-1716781093102-30560313.png create mode 100644 uploads/image-1716781277009-379093839.png diff --git a/app.js b/app.js index 55feaa0..37ed8b3 100644 --- a/app.js +++ b/app.js @@ -1,11 +1,44 @@ import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; +import multer from "multer"; +import path from "path"; import { assert } from "superstruct"; import { CreateArticle, CreateComment, CreateProduct, PatchArticle, PatchComment, PatchProduct } from "./structs.js"; +const SERVER_URL = "http://localhost:3000"; const prisma = new PrismaClient(); const app = express(); app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, "uploads/"); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); + cb(null, file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)); + }, +}); + +const upload = multer({ storage: storage }); + +app.post("/images/upload", upload.single("image"), async (req, res) => { + const file = req.file; + console.log(req); + if (!file) { + return res.status(400).send("이미지 파일을 선택해주세요."); + } + const imagePath = file.path; + const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; + const image = await prisma.image.create({ + data: { + imagePath: imagePath, + }, + }); + + res.status(200).json({ url: imageUrl }); +}); const asyncHandler = (handler) => { return async (req, res) => { diff --git a/package-lock.json b/package-lock.json index 2db532a..1a2d057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "express": "^4.18.2", "is-email": "^1.0.2", "is-uuid": "^1.0.2", + "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" }, @@ -99,6 +100,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -180,6 +186,22 @@ "node": ">=8" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -236,6 +258,20 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -268,6 +304,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -705,6 +746,11 @@ "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -768,11 +814,47 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -818,6 +900,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -877,6 +967,11 @@ "node": ">=16.13" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -931,6 +1026,25 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1087,6 +1201,27 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/superstruct": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.4.tgz", @@ -1148,6 +1283,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1162,6 +1302,11 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1177,6 +1322,14 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index b61b442..52e3a0a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "express": "^4.18.2", "is-email": "^1.0.2", "is-uuid": "^1.0.2", + "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" }, diff --git a/prisma/migrations/20240527032014_add_product_and_article_image_model/migration.sql b/prisma/migrations/20240527032014_add_product_and_article_image_model/migration.sql new file mode 100644 index 0000000..55bd59b --- /dev/null +++ b/prisma/migrations/20240527032014_add_product_and_article_image_model/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "Image" ( + "id" TEXT NOT NULL, + "imagePath" TEXT NOT NULL, + "productId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "articleId" TEXT, + + CONSTRAINT "Image_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Image_productId_idx" ON "Image"("productId"); + +-- CreateIndex +CREATE INDEX "Image_articleId_idx" ON "Image"("articleId"); + +-- AddForeignKey +ALTER TABLE "Image" ADD CONSTRAINT "Image_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Image" ADD CONSTRAINT "Image_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56b0da4..f586145 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model Product { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] + Image Image[] } model Article { @@ -39,6 +40,7 @@ model Article { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] + Image Image[] } model Comment { @@ -55,3 +57,17 @@ model Comment { @@index([productId]) @@index([articleId]) } + +model Image { + id String @id @default(uuid()) + imagePath String + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + productId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull) + articleId String? + + @@index([productId]) + @@index([articleId]) +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..55b802b --- /dev/null +++ b/test.html @@ -0,0 +1,17 @@ + + + + + + + Document + + + +
+ + +
+ + + \ No newline at end of file diff --git a/uploads/image-1716781093102-30560313.png b/uploads/image-1716781093102-30560313.png new file mode 100644 index 0000000000000000000000000000000000000000..16137a61fb1ea45310228c387e52cc308aa8708a GIT binary patch literal 62686 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 Date: Mon, 27 May 2024 14:00:30 +0900 Subject: [PATCH 23/36] =?UTF-8?q?[#M10]=20feat=20:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 580 +----------------- .../migration.sql | 60 ++ prisma/mock.js | 3 - prisma/schema.prisma | 54 +- routes/articles.js | 295 +++++++++ routes/images.js | 41 ++ routes/products.js | 252 ++++++++ uploads/.gitkeep | 0 utils/asyncHandler.js | 26 + 9 files changed, 724 insertions(+), 587 deletions(-) create mode 100644 prisma/migrations/20240527043147_add_user_and_favorite_model_and_change_product_model/migration.sql create mode 100644 routes/articles.js create mode 100644 routes/images.js create mode 100644 routes/products.js create mode 100644 uploads/.gitkeep create mode 100644 utils/asyncHandler.js diff --git a/app.js b/app.js index 37ed8b3..bb5098e 100644 --- a/app.js +++ b/app.js @@ -1,579 +1,15 @@ -import { Prisma, PrismaClient } from "@prisma/client"; import express from "express"; -import multer from "multer"; -import path from "path"; -import { assert } from "superstruct"; -import { CreateArticle, CreateComment, CreateProduct, PatchArticle, PatchComment, PatchProduct } from "./structs.js"; -const SERVER_URL = "http://localhost:3000"; - -const prisma = new PrismaClient(); +import articlesRouter from "./routes/articles.js"; +import imagesRouter from "./routes/images.js"; +import productsRouter from "./routes/products.js"; const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, "uploads/"); - }, - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); - cb(null, file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)); - }, -}); - -const upload = multer({ storage: storage }); - -app.post("/images/upload", upload.single("image"), async (req, res) => { - const file = req.file; - console.log(req); - if (!file) { - return res.status(400).send("이미지 파일을 선택해주세요."); - } - const imagePath = file.path; - const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; - const image = await prisma.image.create({ - data: { - imagePath: imagePath, - }, - }); +app.use("/products", productsRouter); +app.use("/articles", articlesRouter); +app.use("/images", imagesRouter); - res.status(200).json({ url: imageUrl }); +app.listen(3000, () => { + console.log("Server is running on port 3000"); }); - -const asyncHandler = (handler) => { - return async (req, res) => { - try { - await handler(req, res); - } catch (e) { - if (e.name === "StructError") { - const errors = e.failures().map((failure) => ({ - path: failure.path.join("."), - message: failure.message, - })); - res.status(400).json({ message: "유효성 검사 오류입니다.", errors }); - } else if (e instanceof Prisma.PrismaClientValidationError) { - res.status(400).json({ message: e.message }); - } else if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { - res.status(404).json({ message: "존재하지 않는 게시글입니다." }); - } else { - console.error(e); - res.status(500).json({ message: "서버 에러입니다." }); - } - } - }; -}; - -// 상품 목록 조회 -app.get( - "/products", - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 favorite, recent (기본값: recent) - * - keyword : 검색 키워드 - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - const products = await prisma.product.findMany({ - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - name: { - contains: keyword, - mode: "insensitive", - }, - }, - { - description: { - contains: keyword, - mode: "insensitive", - }, - }, - ], - }, - }); - res.send(products); - }) -); - -// 상품 상세 조회 -app.get( - "/products/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - const product = await prisma.product.findUniqueOrThrow({ - where: { - id, - }, - }); - res.send(product); - }) -); - -// 상품 등록 -app.post( - "/products", - asyncHandler(async (req, res) => { - assert(req.body, CreateProduct); - const product = await prisma.product.create({ - data: req.body, - }); - res.status(201).send(product); - }) -); - -// 상품 수정 -app.patch( - "/products/:id", - asyncHandler(async (req, res) => { - assert(req.body, PatchProduct); - const { id } = req.params; - const product = await prisma.product.update({ - where: { - id, - }, - data: req.body, - }); - - res.send(product); - }) -); - -// 상품 삭제 -app.delete( - "/products/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - await prisma.product.delete({ - where: { - id, - }, - }); - - res.sendStatus(204); - }) -); - -// 상품 좋아요 -app.patch( - "/products/:id/like", - asyncHandler(async (req, res) => { - const product = await prisma.product.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); - - if (product.isFavorite) { - res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); - return; - } - - const updatedProduct = await prisma.product.update({ - where: { - id: req.params.id, - }, - data: { - favoriteCount: { - increment: 1, - }, - isFavorite: true, - }, - }); - - res.send(updatedProduct); - }) -); - -// 상품 좋아요 취소 -app.patch( - "/products/:id/unlike", - asyncHandler(async (req, res) => { - const product = await prisma.product.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); - - if (!product.isFavorite) { - res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); - return; - } - - const updatedProduct = await prisma.product.update({ - where: { - id: req.params.id, - }, - data: { - favoriteCount: { - decrement: 1, - }, - isFavorite: false, - }, - }); - - res.send(updatedProduct); - }) -); - -// 게시글 목록 조회 -app.get( - "/articles", - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 like, recent (기본값: recent) - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; - const articles = await prisma.article.findMany({ - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - writer: true, - }, - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - title: { - contains: keyword, - mode: "insensitive", - }, - }, - { - content: { - contains: keyword, - mode: "insensitive", - }, - }, - ], - }, - }); - // 좋아요가 많은 상위 4개의 글 조회 - const bestArticles = await prisma.article.findMany({ - orderBy: { - likeCount: "desc", - }, - take: 4, - }); - - res.send({ articles, bestArticles }); - }) -); - -// 게시글 상세 조회 -app.get( - "/articles/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - const article = await prisma.article.findUniqueOrThrow({ - where: { - id, - }, - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - likeCount: true, - isLiked: true, - writer: true, - }, - }); - res.send(article); - }) -); - -// 게시글 등록 -app.post( - "/articles", - asyncHandler(async (req, res) => { - assert(req.body, CreateArticle); - const article = await prisma.article.create({ - data: req.body, - }); - res.status(201).send(article); - }) -); - -// 게시글 수정 -app.patch( - "/articles/:id", - asyncHandler(async (req, res) => { - assert(req.body, PatchArticle); - const { id } = req.params; - const article = await prisma.article.update({ - where: { - id, - }, - data: req.body, - }); - - res.send(article); - }) -); - -// 게시글 삭제 -app.delete( - "/articles/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - await prisma.article.delete({ - where: { - id, - }, - }); - - res.sendStatus(204); - }) -); - -// 게시글 좋아요 -app.patch( - "/articles/:id/like", - asyncHandler(async (req, res) => { - const article = await prisma.article.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); - - if (article.isLiked) { - res.status(400).send({ message: "이미 좋아요 처리된 게시글입니다." }); - return; - } - - const updatedArticle = await prisma.article.update({ - where: { - id: req.params.id, - }, - data: { - likeCount: { - increment: 1, - }, - isLiked: true, - }, - }); - - res.send(updatedArticle); - }) -); - -// 게시글 좋아요 취소 -app.patch( - "/articles/:id/unlike", - asyncHandler(async (req, res) => { - const article = await prisma.article.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); - - if (!article.isLiked) { - res.status(400).send({ message: "아직 좋아요 처리되지 않은 게시글입니다." }); - return; - } - - const updatedArticle = await prisma.article.update({ - where: { - id: req.params.id, - }, - data: { - likeCount: { - decrement: 1, - }, - isLiked: false, - }, - }); - - res.send(updatedArticle); - }) -); - -// 중고마켓 댓글 목록 조회 -app.get( - "/products/:id/comments", - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - productId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, - }, - skip: 1, - }; - } - - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) -); - -// 중고마켓 댓글 등록 -app.post( - "/products/:id/comments", - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - const { id } = req.params; - - const comment = await prisma.comment.create({ - data: { - ...req.body, - productId: id, - }, - }); - res.status(201).send(comment); - }) -); - -// 중고마켓 댓글 수정 -app.patch( - "/products/:id/comments/:commentId", - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, - }); - - res.send(comment); - }) -); - -// 중고마켓 댓글 삭제 -app.delete( - "/products/:id/comments/:commentId", - asyncHandler(async (req, res) => { - const { commentId } = req.params; - await prisma.comment.delete({ - where: { - id: commentId, - }, - }); - - res.sendStatus(204); - }) -); - -// 자유게시판 댓글 목록 조회 -app.get( - "/articles/:id/comments", - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - articleId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, - }, - skip: 1, - }; - } - - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) -); - -// 자유게시판 댓글 등록 -app.post( - "/articles/:id/comments", - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - const { id } = req.params; - - const comment = await prisma.comment.create({ - data: { - ...req.body, - articleId: id, - }, - }); - res.status(201).send(comment); - }) -); - -// 자유게시판 댓글 수정 -app.patch( - "/articles/:id/comments/:commentId", - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, - }); - - res.send(comment); - }) -); - -// 중고마켓 댓글 삭제 -app.delete( - "/articles/:id/comments/:commentId", - asyncHandler(async (req, res) => { - const { commentId } = req.params; - await prisma.comment.delete({ - where: { - id: commentId, - }, - }); - - res.sendStatus(204); - }) -); - -app.listen(process.env.PORT || 3000, () => console.log("Server Started")); diff --git a/prisma/migrations/20240527043147_add_user_and_favorite_model_and_change_product_model/migration.sql b/prisma/migrations/20240527043147_add_user_and_favorite_model_and_change_product_model/migration.sql new file mode 100644 index 0000000..50bbf1d --- /dev/null +++ b/prisma/migrations/20240527043147_add_user_and_favorite_model_and_change_product_model/migration.sql @@ -0,0 +1,60 @@ +/* + Warnings: + + - You are about to drop the column `isLiked` on the `Article` table. All the data in the column will be lost. + - You are about to drop the column `isFavorite` on the `Product` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Article" DROP COLUMN "isLiked", +ADD COLUMN "userId" INTEGER; + +-- AlterTable +ALTER TABLE "Product" DROP COLUMN "isFavorite", +ADD COLUMN "userId" INTEGER; + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "nickname" TEXT NOT NULL, + "image" TEXT, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Favorite" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + "productId" TEXT, + "articleId" TEXT, + + CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Favorite_userId_productId_articleId_key" ON "Favorite"("userId", "productId", "articleId"); + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/mock.js b/prisma/mock.js index 0b4e46e..a68eed2 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -30,7 +30,6 @@ export const ARTICLES = [ content: "판다인형 구매 후기입니다.", imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", likeCount: 7, - isLiked: false, writer: "판다인형 수집가", }, { @@ -39,7 +38,6 @@ export const ARTICLES = [ content: "판다인형 판매 후기입니다.", imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", likeCount: 2, - isLiked: true, writer: "판다인형 중개인", }, { @@ -48,7 +46,6 @@ export const ARTICLES = [ content: "불곰인형 구하는 곳 아시는분 계신가요?", imageUrl: "https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg", likeCount: 3, - isLiked: false, writer: "불곰인형 수집가", }, ]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f586145..dae5e66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,7 +3,6 @@ // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -13,34 +12,52 @@ datasource db { url = env("DATABASE_URL") } +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + nickname String + image String? + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + products Product[] + articles Article[] + favorites Favorite[] +} + model Product { - id String @id @default(uuid()) + id String @id @default(uuid()) name String description String price Float tags String[] images String[] - favoriteCount Int @default(0) - isFavorite Boolean @default(false) + favoriteCount Int @default(0) ownerId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt comments Comment[] Image Image[] + User User? @relation(fields: [userId], references: [id]) + userId Int? + favorites Favorite[] } model Article { - id String @id @default(uuid()) + id String @id @default(uuid()) title String content String - imageUrl String? @default("") - likeCount Int @default(0) - isLiked Boolean @default(false) + imageUrl String? @default("") + likeCount Int @default(0) writer String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt comments Comment[] Image Image[] + User User? @relation(fields: [userId], references: [id]) + userId Int? + favorites Favorite[] } model Comment { @@ -71,3 +88,16 @@ model Image { @@index([productId]) @@index([articleId]) } + +model Favorite { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + product Product? @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String? + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + articleId String? + + @@unique([userId, productId, articleId]) +} diff --git a/routes/articles.js b/routes/articles.js new file mode 100644 index 0000000..ae32411 --- /dev/null +++ b/routes/articles.js @@ -0,0 +1,295 @@ +import { PrismaClient } from "@prisma/client"; +import express from "express"; +import { assert } from "superstruct"; +import { CreateArticle, CreateComment, PatchArticle, PatchComment } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +const prisma = new PrismaClient(); +const router = express.Router(); +// 게시글 목록 조회 +router.get( + "/", + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 like, recent (기본값: recent) + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; + const articles = await prisma.article.findMany({ + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + writer: true, + }, + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + title: { + contains: keyword, + mode: "insensitive", + }, + }, + { + content: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); + // 좋아요가 많은 상위 4개의 글 조회 + const bestArticles = await prisma.article.findMany({ + orderBy: { + likeCount: "desc", + }, + take: 4, + }); + + res.send({ articles, bestArticles }); + }) +); + +// 게시글 상세 조회 +router.get( + "/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + const article = await prisma.article.findUniqueOrThrow({ + where: { + id, + }, + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + likeCount: true, + writer: true, + }, + }); + res.send(article); + }) +); + +// 게시글 등록 +router.post( + "/", + asyncHandler(async (req, res) => { + assert(req.body, CreateArticle); + const article = await prisma.article.create({ + data: req.body, + }); + res.status(201).send(article); + }) +); + +// 게시글 수정 +router.patch( + "/:id", + asyncHandler(async (req, res) => { + assert(req.body, PatchArticle); + const { id } = req.params; + const article = await prisma.article.update({ + where: { + id, + }, + data: req.body, + }); + + res.send(article); + }) +); + +// 게시글 삭제 +router.delete( + "/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + await prisma.article.delete({ + where: { + id, + }, + }); + + res.sendStatus(204); + }) +); + +// 게시글 좋아요 +router.patch( + "/:id/like", + asyncHandler(async (req, res) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId_articleId: { + userId: req.userId, + articleId: req.params.id, + }, + }, + }); + + if (favorite) { + res.status(400).send({ message: "이미 좋아요 처리된 게시글입니다." }); + return; + } + + const updatedArticle = await prisma.article.update({ + where: { + id: req.params.id, + }, + data: { + likeCount: { + increment: 1, + }, + isLiked: true, + }, + }); + + res.send(updatedArticle); + }) +); + +// 게시글 좋아요 취소 +router.patch( + "/:id/unlike", + asyncHandler(async (req, res) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId_articleId: { + userId: req.userId, + articleId: req.params.id, + }, + }, + }); + + if (!favorite) { + res.status(400).send({ message: "아직 좋아요 처리되지 않은 게시글입니다." }); + return; + } + + const updatedArticle = await prisma.article.update({ + where: { + id: req.params.id, + }, + data: { + likeCount: { + decrement: 1, + }, + isLiked: false, + }, + }); + + res.send(updatedArticle); + }) +); + +// 자유게시판 댓글 목록 조회 +router.get( + "/:id/comments", + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + articleId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) +); + +// 자유게시판 댓글 등록 +router.post( + "/:id/comments", + asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + const { id } = req.params; + + const comment = await prisma.comment.create({ + data: { + ...req.body, + articleId: id, + }, + }); + res.status(201).send(comment); + }) +); + +// 자유게시판 댓글 수정 +router.patch( + "/:id/comments/:commentId", + asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); + + res.send(comment); + }) +); + +// 중고마켓 댓글 삭제 +router.delete( + "/:id/comments/:commentId", + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); + + res.sendStatus(204); + }) +); + +export default router; diff --git a/routes/images.js b/routes/images.js new file mode 100644 index 0000000..8b0876c --- /dev/null +++ b/routes/images.js @@ -0,0 +1,41 @@ +import { PrismaClient } from "@prisma/client"; +import express from "express"; +import multer from "multer"; +import path from "path"; + +const prisma = new PrismaClient(); +const SERVER_URL = "http://localhost:3000"; + +const router = express.Router(); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, "uploads/"); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); + cb(null, file.fieldname + "-" + uniqueSuffix + path.extname(file.originalname)); + }, +}); + +const upload = multer({ storage: storage }); + +// 이미지 업로드 +router.post("/upload", upload.single("image"), async (req, res) => { + const file = req.file; + console.log(req); + if (!file) { + return res.status(400).send("이미지 파일을 선택해주세요."); + } + const imagePath = file.path; + const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; + const image = await prisma.image.create({ + data: { + imagePath: imagePath, + }, + }); + + res.status(200).json({ url: imageUrl }); +}); + +export default router; diff --git a/routes/products.js b/routes/products.js new file mode 100644 index 0000000..2bea203 --- /dev/null +++ b/routes/products.js @@ -0,0 +1,252 @@ +import { PrismaClient } from "@prisma/client"; +import express from "express"; +import { assert } from "superstruct"; +import { CreateComment, CreateProduct, PatchComment, PatchProduct } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +const prisma = new PrismaClient(); +const router = express.Router(); // 상품 목록 조회 +router.get( + "/", + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 favorite, recent (기본값: recent) + * - keyword : 검색 키워드 + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + const products = await prisma.product.findMany({ + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + name: { + contains: keyword, + mode: "insensitive", + }, + }, + { + description: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); + res.send(products); + }) +); + +// 상품 상세 조회 +router.get( + "/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + const product = await prisma.product.findUniqueOrThrow({ + where: { + id, + }, + }); + res.send(product); + }) +); + +// 상품 등록 +router.post( + "/", + asyncHandler(async (req, res) => { + assert(req.body, CreateProduct); + const product = await prisma.product.create({ + data: req.body, + }); + res.status(201).send(product); + }) +); + +// 상품 수정 +router.patch( + "/:id", + asyncHandler(async (req, res) => { + assert(req.body, PatchProduct); + const { id } = req.params; + const product = await prisma.product.update({ + where: { + id, + }, + data: req.body, + }); + + res.send(product); + }) +); + +// 상품 삭제 +router.delete( + "/:id", + asyncHandler(async (req, res) => { + const { id } = req.params; + await prisma.product.delete({ + where: { + id, + }, + }); + + res.sendStatus(204); + }) +); + +// 상품 좋아요 +router.patch( + "/:id/like", + asyncHandler(async (req, res) => { + const product = await prisma.product.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (product.isFavorite) { + res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); + return; + } + + const updatedProduct = await prisma.product.update({ + where: { + id: req.params.id, + }, + data: { + favoriteCount: { + increment: 1, + }, + isFavorite: true, + }, + }); + + res.send(updatedProduct); + }) +); + +// 상품 좋아요 취소 +router.patch( + "/:id/unlike", + asyncHandler(async (req, res) => { + const product = await prisma.product.findUniqueOrThrow({ + where: { + id: req.params.id, + }, + }); + + if (!product.isFavorite) { + res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); + return; + } + + const updatedProduct = await prisma.product.update({ + where: { + id: req.params.id, + }, + data: { + favoriteCount: { + decrement: 1, + }, + isFavorite: false, + }, + }); + + res.send(updatedProduct); + }) +); + +// 중고마켓 댓글 목록 조회 +router.get( + "/:id/comments", + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + productId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) +); + +// 중고마켓 댓글 등록 +router.post( + "/:id/comments", + asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + const { id } = req.params; + + const comment = await prisma.comment.create({ + data: { + ...req.body, + productId: id, + }, + }); + res.status(201).send(comment); + }) +); + +// 중고마켓 댓글 수정 +router.patch( + "/:id/comments/:commentId", + asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); + + res.send(comment); + }) +); + +// 중고마켓 댓글 삭제 +router.delete( + "/:id/comments/:commentId", + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); + + res.sendStatus(204); + }) +); + +export default router; diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utils/asyncHandler.js b/utils/asyncHandler.js new file mode 100644 index 0000000..15c70c4 --- /dev/null +++ b/utils/asyncHandler.js @@ -0,0 +1,26 @@ +import { Prisma } from "@prisma/client"; + +const asyncHandler = (handler) => { + return async (req, res) => { + try { + await handler(req, res); + } catch (e) { + if (e.name === "StructError") { + const errors = e.failures().map((failure) => ({ + path: failure.path.join("."), + message: failure.message, + })); + res.status(400).json({ message: "유효성 검사 오류입니다.", errors }); + } else if (e instanceof Prisma.PrismaClientValidationError) { + res.status(400).json({ message: e.message }); + } else if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { + res.status(404).json({ message: "존재하지 않는 게시글입니다." }); + } else { + console.error(e); + res.status(500).json({ message: "서버 에러입니다." }); + } + } + }; +}; + +export default asyncHandler; From e1f70381a923d243b79f0559d0104f3e35ca70cb Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 15:40:57 +0900 Subject: [PATCH 24/36] =?UTF-8?q?[#M10]=20feat=20:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85,=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 3 + http/auth.http | 29 ++ middlewares/authenticate.js | 25 ++ package-lock.json | 563 +++++++++++++++++++++++++++++++++++- package.json | 2 + routes/auth.js | 97 +++++++ structs.js | 15 +- utils/tokens.js | 14 + 8 files changed, 734 insertions(+), 14 deletions(-) create mode 100644 http/auth.http create mode 100644 middlewares/authenticate.js create mode 100644 routes/auth.js create mode 100644 utils/tokens.js diff --git a/app.js b/app.js index bb5098e..e8f8057 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,9 @@ import express from "express"; import articlesRouter from "./routes/articles.js"; +import authRouter from "./routes/auth.js"; import imagesRouter from "./routes/images.js"; import productsRouter from "./routes/products.js"; + const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -9,6 +11,7 @@ app.use(express.urlencoded({ extended: true })); app.use("/products", productsRouter); app.use("/articles", articlesRouter); app.use("/images", imagesRouter); +app.use("/auth", authRouter); app.listen(3000, () => { console.log("Server is running on port 3000"); diff --git a/http/auth.http b/http/auth.http new file mode 100644 index 0000000..da4cb67 --- /dev/null +++ b/http/auth.http @@ -0,0 +1,29 @@ +POST http://localhost:3000/auth/signUp +Content-Type: application/json + +{ + "email":"test4@gmail.com", + "password":"pandapower", + "name":"김판다", + "nickname":"판다의 왕" +} + +### +# 로그인 + +POST http://localhost:3000/auth/signIn +Content-Type: application/json + +{ + "email":"test4@gmail.com", + "password":"pandapower" +} + +### +# 토큰 재발급 +POST http://localhost:3000/auth/refresh-token +Content-Type: application/json + +{ + "refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjcsImlhdCI6MTcxNjc5MTY3NiwiZXhwIjoxNzE3Mzk2NDc2fQ.wcGjW1SsWm7OKELCcKI-1aA9rmxt1FxmE9SYAJJjgwc" +} \ No newline at end of file diff --git a/middlewares/authenticate.js b/middlewares/authenticate.js new file mode 100644 index 0000000..f891647 --- /dev/null +++ b/middlewares/authenticate.js @@ -0,0 +1,25 @@ +import dotenv from "dotenv"; +import jwt from "jsonwebtoken"; + +dotenv.config(); + +const JWT_SECRET = process.env.JWT_SECRET; + +const authenticate = (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.status(401).json({ message: "No token provided" }); + } + + const token = authHeader.split(" ")[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.userId = decoded.userId; + next(); + } catch (error) { + res.status(401).json({ message: "올바르지 않은 토큰입니다." }); + } +}; + +export default authenticate; diff --git a/package-lock.json b/package-lock.json index 1a2d057..9d0d87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,12 @@ "": { "dependencies": { "@prisma/client": "^5.4.2", + "bcrypt": "^5.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", "is-email": "^1.0.2", "is-uuid": "^1.0.2", + "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" @@ -18,6 +20,25 @@ "nodemon": "^3.0.1" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@prisma/client": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.14.0.tgz", @@ -75,6 +96,11 @@ "@prisma/debug": "5.14.0" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -87,6 +113,25 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -105,6 +150,37 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -113,8 +189,20 @@ "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==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -168,7 +256,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -186,6 +273,11 @@ "node": ">=8" } }, + "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==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -252,11 +344,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "1.6.2", @@ -272,6 +379,11 @@ "typedarray": "^0.0.6" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -313,7 +425,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -329,8 +440,7 @@ "node_modules/debug/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/define-data-property": { "version": "1.1.4", @@ -348,6 +458,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -365,6 +480,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -376,11 +499,24 @@ "url": "https://dotenvx.com" } }, + "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==", + "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", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -533,6 +669,33 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=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==" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -555,6 +718,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -573,6 +756,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", + "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/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -638,6 +841,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -664,6 +872,18 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -681,6 +901,16 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "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.", + "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", @@ -720,6 +950,14 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -751,6 +989,103 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "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.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "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==", + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -806,7 +1141,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -822,6 +1156,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -863,6 +1228,30 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", @@ -891,6 +1280,20 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -900,6 +1303,18 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -927,6 +1342,14 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -935,6 +1358,14 @@ "node": ">= 0.8" } }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -1057,6 +1488,21 @@ "node": ">=8.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1085,7 +1531,6 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -1143,6 +1588,11 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -1181,6 +1631,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1222,6 +1677,30 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/superstruct": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-1.0.4.tgz", @@ -1242,6 +1721,33 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1271,6 +1777,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1323,6 +1834,33 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -1330,6 +1868,11 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } } diff --git a/package.json b/package.json index 52e3a0a..3ffcaec 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "dependencies": { "@prisma/client": "^5.4.2", + "bcrypt": "^5.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", "is-email": "^1.0.2", "is-uuid": "^1.0.2", + "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..5e7eee3 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,97 @@ +import { PrismaClient } from "@prisma/client"; +import bcrypt from "bcrypt"; +import dotenv from "dotenv"; +import express from "express"; +import jwt from "jsonwebtoken"; +import { assert } from "superstruct"; +import { CreateUser } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; +import { generateAccessToken, generateRefreshToken } from "../utils/tokens.js"; + +dotenv.config(); +const router = express.Router(); +const prisma = new PrismaClient(); + +const JWT_SECRET = process.env.JWT_SECRET; + +// 회원가입 +router.post( + "/signUp", + asyncHandler(async (req, res) => { + const { email, password, name, nickname } = req.body; + assert(req.body, CreateUser); + const hashedPassword = await bcrypt.hash(password, 10); + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return res.status(400).json({ message: "이미 가입된 이메일입니다." }); + } + + await prisma.user.create({ + data: { + email, + password: hashedPassword, + name, + nickname, + }, + }); + + res.status(201).json({ message: "회원가입이 완료되었습니다." }); + }) +); +// 사용자 로그인 +router.post( + "/signIn", + asyncHandler(async (req, res) => { + const { email, password } = req.body; + + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); + } + + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.json({ accessToken, refreshToken }); + }) +); + +router.post( + "/refresh-token", + asyncHandler(async (req, res) => { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(401).json({ message: "토큰은 필수값입니다." }); + } + + try { + const decoded = jwt.verify(refreshToken, JWT_SECRET); + const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); + + if (!user) { + return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); + } + + const accessToken = generateAccessToken(user); + + res.json({ accessToken }); + } catch (error) { + return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); + } + }) +); + +export default router; diff --git a/structs.js b/structs.js index 74e0e00..0b2d149 100644 --- a/structs.js +++ b/structs.js @@ -1,20 +1,20 @@ +import isEmail from "is-email"; import * as s from "superstruct"; - const PositivePrice = s.refine(s.number(), "PositivePrice", (value) => value > 0 && value < 1000000000); export const CreateProduct = s.object({ ownerId: s.number(), images: s.array(s.string()), - tags: s.array(s.string()), + tags: s.array(s.size(s.string(), 1, 32)), price: PositivePrice, description: s.string(), - name: s.string(), + name: s.size(s.string(), 1, 60), }); export const PatchProduct = s.partial(CreateProduct); export const CreateArticle = s.object({ - title: s.string(), + title: s.size(s.string(), 1, 60), content: s.string(), imageUrl: s.optional(s.string()), writer: s.optional(s.string()), @@ -28,3 +28,10 @@ export const CreateComment = s.object({ }); export const PatchComment = s.partial(CreateComment); + +export const CreateUser = s.object({ + email: s.define("Email", isEmail), + password: s.size(s.string(), 1, 32), + name: s.size(s.string(), 1, 16), + nickname: s.size(s.string(), 1, 16), +}); diff --git a/utils/tokens.js b/utils/tokens.js new file mode 100644 index 0000000..a916fe3 --- /dev/null +++ b/utils/tokens.js @@ -0,0 +1,14 @@ +import dotenv from "dotenv"; +import jwt from "jsonwebtoken"; + +dotenv.config(); + +const { JWT_SECRET, JWT_ACCESS_EXPIRES_IN, JWT_REFRESH_EXPIRES_IN } = process.env; + +export const generateAccessToken = (user) => { + return jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_ACCESS_EXPIRES_IN }); +}; + +export const generateRefreshToken = (user) => { + return jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); +}; From 6fb96d5529dbdc79bccf1480404b1f692a5c63ae Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 16:09:45 +0900 Subject: [PATCH 25/36] =?UTF-8?q?[#M10]=20feat=20:=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5=ED=95=A9,=20API?= =?UTF-8?q?=EC=97=90=20=EC=9D=B8=EA=B0=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/auth.http | 4 +- http/products.http | 8 + middlewares/authenticate.js | 1 + routes/articles.js | 377 ++++++++++++++++++------------------ routes/images.js | 4 +- routes/products.js | 343 ++++++++++++++++---------------- 6 files changed, 366 insertions(+), 371 deletions(-) diff --git a/http/auth.http b/http/auth.http index da4cb67..e60573d 100644 --- a/http/auth.http +++ b/http/auth.http @@ -2,7 +2,7 @@ POST http://localhost:3000/auth/signUp Content-Type: application/json { - "email":"test4@gmail.com", + "email":"test5@gmail.com", "password":"pandapower", "name":"김판다", "nickname":"판다의 왕" @@ -15,7 +15,7 @@ POST http://localhost:3000/auth/signIn Content-Type: application/json { - "email":"test4@gmail.com", + "email":"test5@gmail.com", "password":"pandapower" } diff --git a/http/products.http b/http/products.http index 1126069..3c7b86a 100644 --- a/http/products.http +++ b/http/products.http @@ -18,6 +18,7 @@ GET http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 ### # 상품 등록 POST http://localhost:3000/products +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE Content-Type: application/json { @@ -32,6 +33,7 @@ Content-Type: application/json ### # 상품 수정 PATCH http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE Content-Type: application/json { @@ -44,14 +46,17 @@ Content-Type: application/json ### # 상품 삭제 DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE ### # 상품 좋아요 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/like +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE ### # 상품 좋아요 취소 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/unlike +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE ### # 상품 댓글 조회 @@ -64,6 +69,7 @@ GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments ### # 상품 댓글 등록 POST http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE Content-Type: application/json { @@ -74,6 +80,7 @@ Content-Type: application/json ### # 상품 댓글 수정 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE Content-Type: application/json { @@ -82,4 +89,5 @@ Content-Type: application/json ### # 상품 댓글 삭제 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d \ No newline at end of file diff --git a/middlewares/authenticate.js b/middlewares/authenticate.js index f891647..739b741 100644 --- a/middlewares/authenticate.js +++ b/middlewares/authenticate.js @@ -10,6 +10,7 @@ const authenticate = (req, res, next) => { if (!authHeader) { return res.status(401).json({ message: "No token provided" }); } + console.log(authHeader); const token = authHeader.split(" ")[1]; diff --git a/routes/articles.js b/routes/articles.js index ae32411..c42ced9 100644 --- a/routes/articles.js +++ b/routes/articles.js @@ -1,134 +1,130 @@ import { PrismaClient } from "@prisma/client"; import express from "express"; import { assert } from "superstruct"; +import authenticate from "../middlewares/authenticate.js"; import { CreateArticle, CreateComment, PatchArticle, PatchComment } from "../structs.js"; import asyncHandler from "../utils/asyncHandler.js"; const prisma = new PrismaClient(); const router = express.Router(); -// 게시글 목록 조회 -router.get( - "/", - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 like, recent (기본값: recent) - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; - const articles = await prisma.article.findMany({ - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - writer: true, - }, - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - title: { - contains: keyword, - mode: "insensitive", + +router + .route("/") + .get( + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 like, recent (기본값: recent) + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; + const articles = await prisma.article.findMany({ + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + writer: true, + }, + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + title: { + contains: keyword, + mode: "insensitive", + }, }, - }, - { - content: { - contains: keyword, - mode: "insensitive", + { + content: { + contains: keyword, + mode: "insensitive", + }, }, - }, - ], - }, - }); - // 좋아요가 많은 상위 4개의 글 조회 - const bestArticles = await prisma.article.findMany({ - orderBy: { - likeCount: "desc", - }, - take: 4, - }); - - res.send({ articles, bestArticles }); - }) -); - -// 게시글 상세 조회 -router.get( - "/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - const article = await prisma.article.findUniqueOrThrow({ - where: { - id, - }, - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - likeCount: true, - writer: true, - }, - }); - res.send(article); - }) -); - -// 게시글 등록 -router.post( - "/", - asyncHandler(async (req, res) => { - assert(req.body, CreateArticle); - const article = await prisma.article.create({ - data: req.body, - }); - res.status(201).send(article); - }) -); + ], + }, + }); + // 좋아요가 많은 상위 4개의 글 조회 + const bestArticles = await prisma.article.findMany({ + orderBy: { + likeCount: "desc", + }, + take: 4, + }); -// 게시글 수정 -router.patch( - "/:id", - asyncHandler(async (req, res) => { - assert(req.body, PatchArticle); - const { id } = req.params; - const article = await prisma.article.update({ - where: { - id, - }, - data: req.body, - }); + res.send({ articles, bestArticles }); + }) + ) + .post( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, CreateArticle); + const article = await prisma.article.create({ + data: req.body, + }); + res.status(201).send(article); + }) + ); - res.send(article); - }) -); +router + .route("/:id") + .get( + asyncHandler(async (req, res) => { + const { id } = req.params; + const article = await prisma.article.findUniqueOrThrow({ + where: { + id, + }, + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + likeCount: true, + writer: true, + }, + }); + res.send(article); + }) + ) + .patch( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, PatchArticle); + const { id } = req.params; + const article = await prisma.article.update({ + where: { + id, + }, + data: req.body, + }); -// 게시글 삭제 -router.delete( - "/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - await prisma.article.delete({ - where: { - id, - }, - }); + res.send(article); + }) + ) + .delete( + authenticate, + asyncHandler(async (req, res) => { + const { id } = req.params; + await prisma.article.delete({ + where: { + id, + }, + }); - res.sendStatus(204); - }) -); + res.sendStatus(204); + }) + ); -// 게시글 좋아요 router.patch( "/:id/like", + authenticate, asyncHandler(async (req, res) => { const article = await prisma.article.findUniqueOrThrow({ where: { @@ -166,9 +162,9 @@ router.patch( }) ); -// 게시글 좋아요 취소 router.patch( "/:id/unlike", + authenticate, asyncHandler(async (req, res) => { const article = await prisma.article.findUniqueOrThrow({ where: { @@ -206,90 +202,87 @@ router.patch( }) ); -// 자유게시판 댓글 목록 조회 -router.get( - "/:id/comments", - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - articleId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, +router + .route("/:id/comments") + .get( + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + articleId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, }, - skip: 1, }; - } - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) -); + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } -// 자유게시판 댓글 등록 -router.post( - "/:id/comments", - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - const { id } = req.params; + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) + ) + .post( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + const { id } = req.params; - const comment = await prisma.comment.create({ - data: { - ...req.body, - articleId: id, - }, - }); - res.status(201).send(comment); - }) -); - -// 자유게시판 댓글 수정 -router.patch( - "/:id/comments/:commentId", - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, - }); + const comment = await prisma.comment.create({ + data: { + ...req.body, + articleId: id, + }, + }); + res.status(201).send(comment); + }) + ); - res.send(comment); - }) -); +router + .route("/:id/comments/:commentId") + .patch( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); -// 중고마켓 댓글 삭제 -router.delete( - "/:id/comments/:commentId", - asyncHandler(async (req, res) => { - const { commentId } = req.params; - await prisma.comment.delete({ - where: { - id: commentId, - }, - }); + res.send(comment); + }) + ) + .delete( + authenticate, + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); - res.sendStatus(204); - }) -); + res.sendStatus(204); + }) + ); export default router; diff --git a/routes/images.js b/routes/images.js index 8b0876c..33dbcae 100644 --- a/routes/images.js +++ b/routes/images.js @@ -2,7 +2,7 @@ import { PrismaClient } from "@prisma/client"; import express from "express"; import multer from "multer"; import path from "path"; - +import authenticate from "../middlewares/authenticate.js"; const prisma = new PrismaClient(); const SERVER_URL = "http://localhost:3000"; @@ -21,7 +21,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage }); // 이미지 업로드 -router.post("/upload", upload.single("image"), async (req, res) => { +router.post("/upload", authenticate, upload.single("image"), async (req, res) => { const file = req.file; console.log(req); if (!file) { diff --git a/routes/products.js b/routes/products.js index 2bea203..3f234c4 100644 --- a/routes/products.js +++ b/routes/products.js @@ -1,109 +1,105 @@ import { PrismaClient } from "@prisma/client"; import express from "express"; import { assert } from "superstruct"; +import authenticate from "../middlewares/authenticate.js"; import { CreateComment, CreateProduct, PatchComment, PatchProduct } from "../structs.js"; import asyncHandler from "../utils/asyncHandler.js"; const prisma = new PrismaClient(); -const router = express.Router(); // 상품 목록 조회 -router.get( - "/", - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 favorite, recent (기본값: recent) - * - keyword : 검색 키워드 - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - const products = await prisma.product.findMany({ - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - name: { - contains: keyword, - mode: "insensitive", +const router = express.Router(); +router + .route("/") + .get( + asyncHandler(async (req, res) => { + /** + * 쿼리 파라미터 + * - offset : 가져올 데이터의 시작 지점 + * - limit : 한 번에 가져올 데이터의 개수 + * - orderBy : 정렬 기준 favorite, recent (기본값: recent) + * - keyword : 검색 키워드 + */ + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + const products = await prisma.product.findMany({ + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + name: { + contains: keyword, + mode: "insensitive", + }, }, - }, - { - description: { - contains: keyword, - mode: "insensitive", + { + description: { + contains: keyword, + mode: "insensitive", + }, }, - }, - ], - }, - }); - res.send(products); - }) -); - -// 상품 상세 조회 -router.get( - "/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - const product = await prisma.product.findUniqueOrThrow({ - where: { - id, - }, - }); - res.send(product); - }) -); - -// 상품 등록 -router.post( - "/", - asyncHandler(async (req, res) => { - assert(req.body, CreateProduct); - const product = await prisma.product.create({ - data: req.body, - }); - res.status(201).send(product); - }) -); - -// 상품 수정 -router.patch( - "/:id", - asyncHandler(async (req, res) => { - assert(req.body, PatchProduct); - const { id } = req.params; - const product = await prisma.product.update({ - where: { - id, - }, - data: req.body, - }); - - res.send(product); - }) -); - -// 상품 삭제 -router.delete( - "/:id", - asyncHandler(async (req, res) => { - const { id } = req.params; - await prisma.product.delete({ - where: { - id, - }, - }); + ], + }, + }); + res.send(products); + }) + ) + .post( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, CreateProduct); + const product = await prisma.product.create({ + data: req.body, + }); + res.status(201).send(product); + }) + ); + +router + .route("/:id") + .get( + asyncHandler(async (req, res) => { + const { id } = req.params; + const product = await prisma.product.findUniqueOrThrow({ + where: { + id, + }, + }); + res.send(product); + }) + ) + .patch( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, PatchProduct); + const { id } = req.params; + const product = await prisma.product.update({ + where: { + id, + }, + data: req.body, + }); + + res.send(product); + }) + ) + .delete( + authenticate, + asyncHandler(async (req, res) => { + const { id } = req.params; + await prisma.product.delete({ + where: { + id, + }, + }); - res.sendStatus(204); - }) -); + res.sendStatus(204); + }) + ); -// 상품 좋아요 router.patch( "/:id/like", + authenticate, asyncHandler(async (req, res) => { const product = await prisma.product.findUniqueOrThrow({ where: { @@ -132,9 +128,9 @@ router.patch( }) ); -// 상품 좋아요 취소 router.patch( "/:id/unlike", + authenticate, asyncHandler(async (req, res) => { const product = await prisma.product.findUniqueOrThrow({ where: { @@ -163,90 +159,87 @@ router.patch( }) ); -// 중고마켓 댓글 목록 조회 -router.get( - "/:id/comments", - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - productId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, +router + .route("/:id/comments") + .get( + asyncHandler(async (req, res) => { + const { id } = req.params; + const { cursor } = req.query; + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + productId: id, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, }, - skip: 1, }; - } - - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) -); - -// 중고마켓 댓글 등록 -router.post( - "/:id/comments", - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - const { id } = req.params; - - const comment = await prisma.comment.create({ - data: { - ...req.body, - productId: id, - }, - }); - res.status(201).send(comment); - }) -); - -// 중고마켓 댓글 수정 -router.patch( - "/:id/comments/:commentId", - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, - }); - - res.send(comment); - }) -); -// 중고마켓 댓글 삭제 -router.delete( - "/:id/comments/:commentId", - asyncHandler(async (req, res) => { - const { commentId } = req.params; - await prisma.comment.delete({ - where: { - id: commentId, - }, - }); + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + const comments = await prisma.comment.findMany(queryOptions); + res.send(comments); + }) + ) + .post( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + const { id } = req.params; + + const comment = await prisma.comment.create({ + data: { + ...req.body, + productId: id, + }, + }); + res.status(201).send(comment); + }) + ); + +router + .route("/:id/comments/:commentId") + .patch( + authenticate, + asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + const { commentId } = req.params; + const comment = await prisma.comment.update({ + where: { + id: commentId, + }, + data: req.body, + }); + + res.send(comment); + }) + ) + .delete( + authenticate, + asyncHandler(async (req, res) => { + const { commentId } = req.params; + await prisma.comment.delete({ + where: { + id: commentId, + }, + }); - res.sendStatus(204); - }) -); + res.sendStatus(204); + }) + ); export default router; From 3a16ac9983bd66eec7fbb4bef1fe273359c3093a Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 16:13:20 +0900 Subject: [PATCH 26/36] =?UTF-8?q?[#M10]=20feat=20:=20jwt=20sliding=20sessi?= =?UTF-8?q?on=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/auth.http | 2 +- routes/auth.js | 8 ++++---- utils/tokens.js | 9 +++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/http/auth.http b/http/auth.http index e60573d..c5339ae 100644 --- a/http/auth.http +++ b/http/auth.http @@ -25,5 +25,5 @@ POST http://localhost:3000/auth/refresh-token Content-Type: application/json { - "refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjcsImlhdCI6MTcxNjc5MTY3NiwiZXhwIjoxNzE3Mzk2NDc2fQ.wcGjW1SsWm7OKELCcKI-1aA9rmxt1FxmE9SYAJJjgwc" + "refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5Mzk0NywiZXhwIjoxNzE3Mzk4NzQ3fQ.bBxra1mgW2Pe4s9KcD_8Z5QmuFAIqZhjwKEupNpkXbs" } \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js index 5e7eee3..6152f5f 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -6,7 +6,7 @@ import jwt from "jsonwebtoken"; import { assert } from "superstruct"; import { CreateUser } from "../structs.js"; import asyncHandler from "../utils/asyncHandler.js"; -import { generateAccessToken, generateRefreshToken } from "../utils/tokens.js"; +import { generateAccessToken, generateRefreshToken, regenerateRefreshToken } from "../utils/tokens.js"; dotenv.config(); const router = express.Router(); @@ -67,7 +67,6 @@ router.post( res.json({ accessToken, refreshToken }); }) ); - router.post( "/refresh-token", asyncHandler(async (req, res) => { @@ -78,7 +77,8 @@ router.post( } try { - const decoded = jwt.verify(refreshToken, JWT_SECRET); + const newRefreshToken = regenerateRefreshToken(refreshToken); + const decoded = jwt.verify(newRefreshToken, JWT_SECRET); const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); if (!user) { @@ -87,7 +87,7 @@ router.post( const accessToken = generateAccessToken(user); - res.json({ accessToken }); + res.json({ accessToken, refreshToken: newRefreshToken }); } catch (error) { return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); } diff --git a/utils/tokens.js b/utils/tokens.js index a916fe3..12d5d2e 100644 --- a/utils/tokens.js +++ b/utils/tokens.js @@ -12,3 +12,12 @@ export const generateAccessToken = (user) => { export const generateRefreshToken = (user) => { return jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); }; + +export const regenerateRefreshToken = (refreshToken) => { + try { + const decoded = jwt.verify(refreshToken, JWT_SECRET); + return jwt.sign({ userId: decoded.userId }, JWT_SECRET, { expiresIn: "7d" }); + } catch (error) { + throw new Error("Invalid refresh token"); + } +}; From 98e2a967465b2943132a46943f6b4a6ec5442452 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 16:39:23 +0900 Subject: [PATCH 27/36] =?UTF-8?q?[#M10]=20fix=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EC=8B=9C=20=EC=9C=A0=EC=A0=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mock, seed 데이터 수정 --- http/products.http | 4 +- middlewares/authenticate.js | 1 - .../migration.sql | 15 ++++++ .../migration.sql | 18 +++++++ prisma/mock.js | 24 ++++++++- prisma/schema.prisma | 8 +-- prisma/seed.js | 16 +++++- routes/products.js | 54 +++++++++++++++---- 8 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20240527072552_change_favorite_model/migration.sql create mode 100644 prisma/migrations/20240527074901_rename_owner_id_to_user_id/migration.sql diff --git a/http/products.http b/http/products.http index 3c7b86a..e0afdbf 100644 --- a/http/products.http +++ b/http/products.http @@ -51,12 +51,12 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhd ### # 상품 좋아요 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/like -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5NDc3MCwiZXhwIjoxNzE2Nzk1NjcwfQ.4jHyaxo0BgeSCSfTGAwnTlMLyRZGEJPoEOqUe-Xdo9w ### # 상품 좋아요 취소 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/unlike -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5NDc3MCwiZXhwIjoxNzE2Nzk1NjcwfQ.4jHyaxo0BgeSCSfTGAwnTlMLyRZGEJPoEOqUe-Xdo9w ### # 상품 댓글 조회 diff --git a/middlewares/authenticate.js b/middlewares/authenticate.js index 739b741..f891647 100644 --- a/middlewares/authenticate.js +++ b/middlewares/authenticate.js @@ -10,7 +10,6 @@ const authenticate = (req, res, next) => { if (!authHeader) { return res.status(401).json({ message: "No token provided" }); } - console.log(authHeader); const token = authHeader.split(" ")[1]; diff --git a/prisma/migrations/20240527072552_change_favorite_model/migration.sql b/prisma/migrations/20240527072552_change_favorite_model/migration.sql new file mode 100644 index 0000000..171efba --- /dev/null +++ b/prisma/migrations/20240527072552_change_favorite_model/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,productId]` on the table `Favorite` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[userId,articleId]` on the table `Favorite` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Favorite_userId_productId_articleId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Favorite_userId_productId_key" ON "Favorite"("userId", "productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Favorite_userId_articleId_key" ON "Favorite"("userId", "articleId"); diff --git a/prisma/migrations/20240527074901_rename_owner_id_to_user_id/migration.sql b/prisma/migrations/20240527074901_rename_owner_id_to_user_id/migration.sql new file mode 100644 index 0000000..b019051 --- /dev/null +++ b/prisma/migrations/20240527074901_rename_owner_id_to_user_id/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - The primary key for the `Favorite` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `ownerId` on the `Product` table. All the data in the column will be lost. + - You are about to alter the column `price` on the `Product` table. The data in that column could be lost. The data in that column will be cast from `DoublePrecision` to `Integer`. + +*/ +-- AlterTable +ALTER TABLE "Favorite" DROP CONSTRAINT "Favorite_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Favorite_id_seq"; + +-- AlterTable +ALTER TABLE "Product" DROP COLUMN "ownerId", +ALTER COLUMN "price" SET DATA TYPE INTEGER; diff --git a/prisma/mock.js b/prisma/mock.js index a68eed2..5cf4342 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -2,17 +2,16 @@ export const PRODUCTS = [ { id: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", favoriteCount: 7, - ownerId: 1, images: ["https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg"], tags: ["판다인형", "인형", "판다"], price: 700000, description: "판다인형 판다", name: "판다인형", + userId: 1, }, { id: "d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9", favoriteCount: 2, - ownerId: 2, images: [ "https://view01.wemep.co.kr/wmp-product/4/879/2515748794/pm_ebifv5nrjsyf.jpg?1683280710&f=webp&w=460&h=460", ], @@ -20,6 +19,7 @@ export const PRODUCTS = [ price: 7000, description: "판다인형 안판다", name: "판다인형 안파는 판다", + userId: 1, }, ]; @@ -141,3 +141,23 @@ export const COMMENTS = [ articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", }, ]; + +export const USERS = [ + { + id: 1, + email: "test@gmail.com", + name: "김판다", + nickname: "판다의 왕", + password: "$2b$10$cq6uUbVgrWG9UG6Gpu/k3un1aTLE8XzMfAyU2NhS2FIY93M.CFXqO", + createdAt: new Date(), + updatedAt: new Date(), + }, +]; + +export const FAVORITE = [ + { + id: "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p", + userId: 1, + productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + }, +]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dae5e66..b1d6c8f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,11 +30,10 @@ model Product { id String @id @default(uuid()) name String description String - price Float + price Int tags String[] images String[] favoriteCount Int @default(0) - ownerId Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -90,7 +89,7 @@ model Image { } model Favorite { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @@ -99,5 +98,6 @@ model Favorite { article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) articleId String? - @@unique([userId, productId, articleId]) + @@unique([userId, productId]) + @@unique([userId, articleId]) } diff --git a/prisma/seed.js b/prisma/seed.js index 92f7f42..7e067cf 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,8 +1,14 @@ import { PrismaClient } from "@prisma/client"; -import { ARTICLES, COMMENTS, PRODUCTS } from "./mock.js"; +import { ARTICLES, COMMENTS, FAVORITE, PRODUCTS, USERS } from "./mock.js"; const prisma = new PrismaClient(); async function main() { + await prisma.user.deleteMany(); + + await prisma.user.createMany({ + data: USERS, + skipDuplicates: true, + }); await prisma.product.deleteMany(); await prisma.product.createMany({ @@ -16,6 +22,14 @@ async function main() { data: ARTICLES, skipDuplicates: true, }); + + await prisma.favorite.deleteMany(); + + await prisma.favorite.createMany({ + data: FAVORITE, + skipDuplicates: true, + }); + await prisma.comment.deleteMany(); await prisma.comment.createMany({ diff --git a/routes/products.js b/routes/products.js index 3f234c4..035a71a 100644 --- a/routes/products.js +++ b/routes/products.js @@ -101,26 +101,43 @@ router.patch( "/:id/like", authenticate, asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; const product = await prisma.product.findUniqueOrThrow({ where: { - id: req.params.id, + id: productId, }, }); - if (product.isFavorite) { + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }); + + if (favorite) { res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); return; } + await prisma.favorite.create({ + data: { + userId, + productId, + }, + }); + const updatedProduct = await prisma.product.update({ where: { - id: req.params.id, + id: productId, }, data: { favoriteCount: { increment: 1, }, - isFavorite: true, }, }); @@ -132,26 +149,43 @@ router.patch( "/:id/unlike", authenticate, asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; const product = await prisma.product.findUniqueOrThrow({ where: { - id: req.params.id, + id: productId, }, }); - if (!product.isFavorite) { - res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); - return; + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }); + + if (!favorite) { + return res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); } + await prisma.favorite.delete({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }); const updatedProduct = await prisma.product.update({ where: { - id: req.params.id, + id: productId, }, data: { favoriteCount: { decrement: 1, }, - isFavorite: false, }, }); From 94a985538c060c2fd63b8620e903ca68c17cc29e Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Mon, 27 May 2024 16:54:12 +0900 Subject: [PATCH 28/36] =?UTF-8?q?[#M10]=20fix=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EC=9D=B8=EA=B0=80=20=EB=90=9C?= =?UTF-8?q?=20userId=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/products.http | 5 ++--- routes/products.js | 3 ++- structs.js | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/http/products.http b/http/products.http index e0afdbf..79fd882 100644 --- a/http/products.http +++ b/http/products.http @@ -18,7 +18,7 @@ GET http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 ### # 상품 등록 POST http://localhost:3000/products -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjc5NjIzMiwiZXhwIjoxNzE2Nzk3MTMyfQ.h5Jq-cMim3LixOqibNgMh3yAr0m03ajCXaxBUb25v3M Content-Type: application/json { @@ -26,8 +26,7 @@ Content-Type: application/json "description": "세종시청에서 교환원합니다.", "price": 20000, "tags": ["판다", "불곰"], - "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"], - "ownerId":1 + "images": ["https://www.wishbucket.io/_next/image?url=https%3A%2F%2Fd2gfz7wkiigkmv.cloudfront.net%2Fpickin%2F2%2F1%2F2%2FHereyhSJRMOmUw7I5uWAxg&w=640&q=75","https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg"] } ### diff --git a/routes/products.js b/routes/products.js index 035a71a..b809c28 100644 --- a/routes/products.js +++ b/routes/products.js @@ -48,8 +48,9 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, CreateProduct); + const { userId } = req; const product = await prisma.product.create({ - data: req.body, + data: { ...req.body, userId }, }); res.status(201).send(product); }) diff --git a/structs.js b/structs.js index 0b2d149..a92e0c9 100644 --- a/structs.js +++ b/structs.js @@ -3,7 +3,6 @@ import * as s from "superstruct"; const PositivePrice = s.refine(s.number(), "PositivePrice", (value) => value > 0 && value < 1000000000); export const CreateProduct = s.object({ - ownerId: s.number(), images: s.array(s.string()), tags: s.array(s.size(s.string(), 1, 32)), price: PositivePrice, From 00ab5514915ea42b1fa235ee9aa9692314eddbc8 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 11:49:23 +0900 Subject: [PATCH 29/36] =?UTF-8?q?[#M10]=20feat=20:=20morgan=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EB=A1=9C=EA=B7=B8=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 20 +++++++++++ package-lock.json | 84 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 3 files changed, 106 insertions(+) diff --git a/app.js b/app.js index e8f8057..7a94b64 100644 --- a/app.js +++ b/app.js @@ -1,10 +1,30 @@ import express from "express"; +import fs from "fs"; +import moment from "moment-timezone"; +import morgan from "morgan"; +import path, { dirname } from "path"; +import { fileURLToPath } from "url"; import articlesRouter from "./routes/articles.js"; import authRouter from "./routes/auth.js"; import imagesRouter from "./routes/images.js"; import productsRouter from "./routes/products.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const app = express(); + +morgan.token("date", (req, res, tz) => { + return moment().tz(tz).format("YYYY-MM-DD HH:mm:ss"); +}); +// 커스텀 포맷 설정: 한국 시간대의 현재 시간을 포함 +const customFormat = ":method :url :status :res[content-length] - :response-time ms - :date[Asia/Seoul]"; + +// 로그 파일 스트림 생성 +const accessLogStream = fs.createWriteStream(path.join(__dirname, "access.log"), { flags: "a" }); + +// morgan 미들웨어 설정 (로그 파일에 기록) +app.use(morgan(customFormat, { stream: accessLogStream })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/package-lock.json b/package-lock.json index 9d0d87d..09bccb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "is-email": "^1.0.2", "is-uuid": "^1.0.2", "jsonwebtoken": "^9.0.2", + "moment-timezone": "^0.5.45", + "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" @@ -191,6 +193,22 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -1198,6 +1216,64 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1342,6 +1418,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 3ffcaec..87a3db6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "is-email": "^1.0.2", "is-uuid": "^1.0.2", "jsonwebtoken": "^9.0.2", + "moment-timezone": "^0.5.45", + "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", "prisma": "^5.4.2", "superstruct": "^1.0.3" From 2f4ae013febe56c9fe1bc14b19c70ca90e91952d Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 12:29:00 +0900 Subject: [PATCH 30/36] =?UTF-8?q?[#M10]=20fix=20:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20API=EB=93=A4=EC=9D=B4=20userId=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상품 수정, 삭제 - 상품 댓글 등록, 수정, 삭제 --- http/auth.http | 2 +- http/products.http | 20 ++--- .../migration.sql | 11 +++ prisma/mock.js | 49 ++++++++++-- prisma/schema.prisma | 3 + routes/products.js | 76 +++++++++++++------ 6 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 prisma/migrations/20240528031512_add_user_id_to_comment/migration.sql diff --git a/http/auth.http b/http/auth.http index c5339ae..4868f2e 100644 --- a/http/auth.http +++ b/http/auth.http @@ -15,7 +15,7 @@ POST http://localhost:3000/auth/signIn Content-Type: application/json { - "email":"test5@gmail.com", + "email":"test2@gmail.com", "password":"pandapower" } diff --git a/http/products.http b/http/products.http index 79fd882..315fe1a 100644 --- a/http/products.http +++ b/http/products.http @@ -18,7 +18,7 @@ GET http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 ### # 상품 등록 POST http://localhost:3000/products -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjc5NjIzMiwiZXhwIjoxNzE2Nzk3MTMyfQ.h5Jq-cMim3LixOqibNgMh3yAr0m03ajCXaxBUb25v3M +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E Content-Type: application/json { @@ -32,7 +32,7 @@ Content-Type: application/json ### # 상품 수정 PATCH http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTMyNSwiZXhwIjoxNzE2ODY2MjI1fQ.yHZTsbdl-ZWTUM0_5RTe6wt2mAgF11uLTmN0MuLda9U Content-Type: application/json { @@ -44,13 +44,13 @@ Content-Type: application/json ### # 상품 삭제 -DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +DELETE http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTMyNSwiZXhwIjoxNzE2ODY2MjI1fQ.yHZTsbdl-ZWTUM0_5RTe6wt2mAgF11uLTmN0MuLda9U ### # 상품 좋아요 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/like -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5NDc3MCwiZXhwIjoxNzE2Nzk1NjcwfQ.4jHyaxo0BgeSCSfTGAwnTlMLyRZGEJPoEOqUe-Xdo9w +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NjY3NiwiZXhwIjoxNzE2ODY3NTc2fQ.TiC1uKaRL28Y1WM-ADHCzo0Ply-l_IfYZx9e_rjyEqI ### # 상품 좋아요 취소 @@ -68,7 +68,7 @@ GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments ### # 상품 댓글 등록 POST http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E Content-Type: application/json { @@ -78,8 +78,8 @@ Content-Type: application/json ### # 상품 댓글 수정 -PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/9d7d8a72-693e-44d3-bf72-889c9ebac0d9 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E Content-Type: application/json { @@ -88,5 +88,5 @@ Content-Type: application/json ### # 상품 댓글 삭제 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5MzQwOSwiZXhwIjoxNzE2Nzk0MzA5fQ.uWsTo5_mKNsXmWdYpYlE492MMv_PV513S2mbs_bELTE -DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/4db38f1c-53c5-40c4-98ce-bcaa0ba7904d \ No newline at end of file +DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/9d7d8a72-693e-44d3-bf72-889c9ebac0d9 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NjY3NiwiZXhwIjoxNzE2ODY3NTc2fQ.TiC1uKaRL28Y1WM-ADHCzo0Ply-l_IfYZx9e_rjyEqI \ No newline at end of file diff --git a/prisma/migrations/20240528031512_add_user_id_to_comment/migration.sql b/prisma/migrations/20240528031512_add_user_id_to_comment/migration.sql new file mode 100644 index 0000000..b5f13a6 --- /dev/null +++ b/prisma/migrations/20240528031512_add_user_id_to_comment/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `userId` to the `Comment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "userId" INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/mock.js b/prisma/mock.js index 5cf4342..255b921 100644 --- a/prisma/mock.js +++ b/prisma/mock.js @@ -15,11 +15,11 @@ export const PRODUCTS = [ images: [ "https://view01.wemep.co.kr/wmp-product/4/879/2515748794/pm_ebifv5nrjsyf.jpg?1683280710&f=webp&w=460&h=460", ], - tags: ["판다인형", "인형", "판다"], + tags: ["판다인형", "인형", "판다", "불곰"], price: 7000, description: "판다인형 안판다", - name: "판다인형 안파는 판다", - userId: 1, + name: "불곰사세요", + userId: 2, }, ]; @@ -30,7 +30,7 @@ export const ARTICLES = [ content: "판다인형 구매 후기입니다.", imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", likeCount: 7, - writer: "판다인형 수집가", + userId: 1, }, { id: "7c8b9d2e-5d45-4c9f-9b4b-7626f3c9c9a9", @@ -38,7 +38,7 @@ export const ARTICLES = [ content: "판다인형 판매 후기입니다.", imageUrl: "https://sitem.ssgcdn.com/62/11/49/item/1000559491162_i1_1100.jpg", likeCount: 2, - writer: "판다인형 중개인", + userId: 1, }, { id: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", @@ -46,7 +46,7 @@ export const ARTICLES = [ content: "불곰인형 구하는 곳 아시는분 계신가요?", imageUrl: "https://wimg.mk.co.kr/meet/2021/09/image_listtop_2021_854860_1630738087.jpg", likeCount: 3, - writer: "불곰인형 수집가", + userId: 2, }, ]; @@ -56,89 +56,106 @@ export const COMMENTS = [ content: "판다인형 너무 귀여워요!", writer: "판다인형 수집가", articleId: "2c027764-d7ef-4a94-8399-f15ffbf8f4da", + userId: 1, }, { id: "2b3c4d5e-6f7g-8h9i-0j1k-2l3m4n5o6p7q", content: "판다인형 너무 귀여워요1", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요2", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요3", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요4", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요5", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요6", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요7", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요8", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요9", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요10", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요11", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { content: "판다인형 너무 귀여워요12", writer: "판다인형 중개인", productId: "377ce06c-23c8-46de-b86f-2cfd43d41cbc", + userId: 1, }, { id: "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", content: "불곰인형 너무 귀여워요!", writer: "불곰인형 수집가", articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + userId: 2, }, { id: "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r", content: "불곰인형 너무 귀여워요1", writer: "불곰인형 수집가", articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + userId: 2, }, { content: "불곰인형 너무 귀여워요2", writer: "불곰인형 수집가", articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + userId: 2, }, { content: "불곰인형 너무 귀여워요3", writer: "불곰인형 수집가", articleId: "287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3", + userId: 2, }, ]; @@ -148,7 +165,25 @@ export const USERS = [ email: "test@gmail.com", name: "김판다", nickname: "판다의 왕", - password: "$2b$10$cq6uUbVgrWG9UG6Gpu/k3un1aTLE8XzMfAyU2NhS2FIY93M.CFXqO", + password: "$2b$10$2vHNtEq54uvrSksq8CPadugrnhYwcPjltPp6z66E85HJJqMLCXGN.", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 2, + email: "test2@gmail.com", + name: "박불곰", + nickname: "킹오브불곰", + password: "$2b$10$2vHNtEq54uvrSksq8CPadugrnhYwcPjltPp6z66E85HJJqMLCXGN.", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 1, + email: "test@gmail.com", + name: "김판다", + nickname: "판다의 왕", + password: "$2b$10$2vHNtEq54uvrSksq8CPadugrnhYwcPjltPp6z66E85HJJqMLCXGN.", createdAt: new Date(), updatedAt: new Date(), }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b1d6c8f..3c3d127 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { products Product[] articles Article[] favorites Favorite[] + Comment Comment[] } model Product { @@ -69,6 +70,8 @@ model Comment { productId String? article Article? @relation(fields: [articleId], references: [id], onDelete: SetNull) articleId String? + userId Int + User User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([productId]) @@index([articleId]) diff --git a/routes/products.js b/routes/products.js index b809c28..a2bf84c 100644 --- a/routes/products.js +++ b/routes/products.js @@ -72,25 +72,42 @@ router .patch( authenticate, asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; + const product = await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + }); + + if (product.userId !== userId) { + return res.status(403).json({ error: "상품을 수정할 권한이 없습니다." }); + } assert(req.body, PatchProduct); - const { id } = req.params; - const product = await prisma.product.update({ + + const updatedProduct = await prisma.product.update({ where: { - id, + id: productId, }, data: req.body, }); - res.send(product); + res.send(updatedProduct); }) ) .delete( authenticate, asyncHandler(async (req, res) => { - const { id } = req.params; + const { id: productId } = req.params; + const { userId } = req; + const product = await prisma.product.findUniqueOrThrow({ + where: { id: productId }, + }); + + if (product.userId !== userId) { + return res.status(403).json({ error: "상품을 삭제할 권한이 없습니다." }); + } await prisma.product.delete({ where: { - id, + id: productId, }, }); @@ -104,11 +121,6 @@ router.patch( asyncHandler(async (req, res) => { const { id: productId } = req.params; const { userId } = req; - const product = await prisma.product.findUniqueOrThrow({ - where: { - id: productId, - }, - }); const favorite = await prisma.favorite.findUnique({ where: { @@ -152,11 +164,6 @@ router.patch( asyncHandler(async (req, res) => { const { id: productId } = req.params; const { userId } = req; - const product = await prisma.product.findUniqueOrThrow({ - where: { - id: productId, - }, - }); const favorite = await prisma.favorite.findUnique({ where: { @@ -179,6 +186,7 @@ router.patch( }, }, }); + const updatedProduct = await prisma.product.update({ where: { id: productId, @@ -234,12 +242,15 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, CreateComment); - const { id } = req.params; + + const { userId } = req; + const { id: productId } = req.params; const comment = await prisma.comment.create({ data: { ...req.body, - productId: id, + productId, + userId, }, }); res.status(201).send(comment); @@ -252,21 +263,36 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, PatchComment); + const { userId } = req; + const { content } = req.body; const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, + const comment = await prisma.comment.findUniqueOrThrow({ + where: { id: commentId }, }); - - res.send(comment); + if (comment.userId !== userId) { + return res.status(403).json({ error: "이 댓글을 수정할 권한이 없습니다." }); + } + const updatedComment = await prisma.comment.update({ + where: { id: commentId }, + data: { content }, + }); + res.send(updatedComment); }) ) .delete( authenticate, asyncHandler(async (req, res) => { const { commentId } = req.params; + const { userId } = req; + + const comment = await prisma.comment.findUniqueOrThrow({ + where: { id: commentId }, + }); + + if (comment.userId !== userId) { + return res.status(403).json({ error: "이 댓글을 삭제할 권한이 없습니다." }); + } + await prisma.comment.delete({ where: { id: commentId, From 33d0c3923f96210614b58ae7ee78f6bd9fc45fc9 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 12:42:29 +0900 Subject: [PATCH 31/36] =?UTF-8?q?[#M10]=20fix=20:=20=EC=9E=90=EC=9C=A0?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20API=EB=93=A4?= =?UTF-8?q?=EC=9D=B4=20userId=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/articles.http | 16 ++++-- routes/articles.js | 126 +++++++++++++++++++++++++++++++-------------- routes/products.js | 13 +++-- 3 files changed, 109 insertions(+), 46 deletions(-) diff --git a/http/articles.http b/http/articles.http index ad5c723..e7add53 100644 --- a/http/articles.http +++ b/http/articles.http @@ -9,6 +9,7 @@ GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 등록 POST http://localhost:3000/articles +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ Content-Type: application/json { @@ -19,7 +20,8 @@ Content-Type: application/json ### # 게시글 수정 -PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da +PATCH http://localhost:3000/articles/9c96039f-e56d-470e-847f-aa24b8da981c +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ Content-Type: application/json { @@ -29,15 +31,18 @@ Content-Type: application/json ### # 게시글 삭제 -DELETE http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da +DELETE http://localhost:3000/articles/9c96039f-e56d-470e-847f-aa24b8da981c +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ ### # 게시글 좋아요 PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/like +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ ### # 게시글 좋아요 취소 PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/unlike +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ @@ -54,6 +59,7 @@ GET http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments # 자유게시판 댓글 등록 POST http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ { "content":"판다가 너무 귀여워요2" @@ -61,8 +67,9 @@ Content-Type: application/json ### # 자유게시판 댓글 수정 -PATCH http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p +PATCH http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/98b65e20-e12b-43fb-8b04-6525b019eb8b Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ { "content":"판다가 너무 귀여워요 수정" @@ -70,4 +77,5 @@ Content-Type: application/json ### # 자유게시판 댓글 삭제 -DELETE http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p \ No newline at end of file +DELETE http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/98b65e20-e12b-43fb-8b04-6525b019eb8b +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ \ No newline at end of file diff --git a/routes/articles.js b/routes/articles.js index c42ced9..881cb3f 100644 --- a/routes/articles.js +++ b/routes/articles.js @@ -64,8 +64,9 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, CreateArticle); + const { userId } = req; const article = await prisma.article.create({ - data: req.body, + data: { ...req.body, userId }, }); res.status(201).send(article); }) @@ -97,24 +98,45 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, PatchArticle); - const { id } = req.params; - const article = await prisma.article.update({ + + const { id: articleId } = req.params; + const { userId } = req; + + const article = await prisma.article.findUniqueOrThrow({ + where: { id: articleId }, + }); + + if (article.userId !== userId) { + return res.status(403).json({ error: "게시글을 수정할 권한이 없습니다." }); + } + + const updatedArticle = await prisma.article.update({ where: { - id, + id: articleId, }, data: req.body, }); - res.send(article); + res.send(updatedArticle); }) ) .delete( authenticate, asyncHandler(async (req, res) => { - const { id } = req.params; + const { id: articleId } = req.params; + const { userId } = req; + + const article = await prisma.article.findUniqueOrThrow({ + where: { id: articleId }, + }); + + if (article.userId !== userId) { + return res.status(403).json({ error: "게시글을 삭제할 권한이 없습니다." }); + } + await prisma.article.delete({ where: { - id, + id: articleId, }, }); @@ -126,17 +148,14 @@ router.patch( "/:id/like", authenticate, asyncHandler(async (req, res) => { - const article = await prisma.article.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); + const { id: articleId } = req.params; + const { userId } = req; const favorite = await prisma.favorite.findUnique({ where: { - userId_productId_articleId: { - userId: req.userId, - articleId: req.params.id, + userId_articleId: { + userId, + articleId, }, }, }); @@ -146,15 +165,21 @@ router.patch( return; } + await prisma.favorite.create({ + data: { + userId, + articleId, + }, + }); + const updatedArticle = await prisma.article.update({ where: { - id: req.params.id, + id: articleId, }, data: { likeCount: { increment: 1, }, - isLiked: true, }, }); @@ -166,17 +191,14 @@ router.patch( "/:id/unlike", authenticate, asyncHandler(async (req, res) => { - const article = await prisma.article.findUniqueOrThrow({ - where: { - id: req.params.id, - }, - }); + const { id: articleId } = req.params; + const { userId } = req; const favorite = await prisma.favorite.findUnique({ where: { - userId_productId_articleId: { - userId: req.userId, - articleId: req.params.id, + userId_articleId: { + userId, + articleId, }, }, }); @@ -186,15 +208,23 @@ router.patch( return; } + await prisma.favorite.delete({ + where: { + userId_articleId: { + userId, + articleId, + }, + }, + }); + const updatedArticle = await prisma.article.update({ where: { - id: req.params.id, + id: articleId, }, data: { likeCount: { decrement: 1, }, - isLiked: false, }, }); @@ -242,12 +272,14 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, CreateComment); - const { id } = req.params; + const { userId } = req; + const { id: articleId } = req.params; const comment = await prisma.comment.create({ data: { ...req.body, - articleId: id, + articleId, + userId, }, }); res.status(201).send(comment); @@ -260,25 +292,43 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, PatchComment); + + const { userId } = req; + const { content } = req.body; const { commentId } = req.params; - const comment = await prisma.comment.update({ - where: { - id: commentId, - }, - data: req.body, + + const comment = await prisma.comment.findUniqueOrThrow({ + where: { id: commentId }, }); - res.send(comment); + if (comment.userId !== userId) { + return res.status(403).json({ error: "이 댓글을 수정할 권한이 없습니다." }); + } + + const updatedComment = await prisma.comment.update({ + where: { id: commentId }, + data: { content }, + }); + + res.send(updatedComment); }) ) .delete( authenticate, asyncHandler(async (req, res) => { const { commentId } = req.params; + const { userId } = req; + + const comment = await prisma.comment.findUniqueOrThrow({ + where: { id: commentId }, + }); + + if (comment.userId !== userId) { + return res.status(403).json({ error: "이 댓글을 삭제할 권한이 없습니다." }); + } + await prisma.comment.delete({ - where: { - id: commentId, - }, + where: { id: commentId }, }); res.sendStatus(204); diff --git a/routes/products.js b/routes/products.js index a2bf84c..eec8014 100644 --- a/routes/products.js +++ b/routes/products.js @@ -72,8 +72,11 @@ router .patch( authenticate, asyncHandler(async (req, res) => { + assert(req.body, PatchProduct); + const { id: productId } = req.params; const { userId } = req; + const product = await prisma.product.findUniqueOrThrow({ where: { id: productId }, }); @@ -81,7 +84,6 @@ router if (product.userId !== userId) { return res.status(403).json({ error: "상품을 수정할 권한이 없습니다." }); } - assert(req.body, PatchProduct); const updatedProduct = await prisma.product.update({ where: { @@ -98,6 +100,7 @@ router asyncHandler(async (req, res) => { const { id: productId } = req.params; const { userId } = req; + const product = await prisma.product.findUniqueOrThrow({ where: { id: productId }, }); @@ -263,15 +266,19 @@ router authenticate, asyncHandler(async (req, res) => { assert(req.body, PatchComment); + const { userId } = req; const { content } = req.body; const { commentId } = req.params; + const comment = await prisma.comment.findUniqueOrThrow({ where: { id: commentId }, }); + if (comment.userId !== userId) { return res.status(403).json({ error: "이 댓글을 수정할 권한이 없습니다." }); } + const updatedComment = await prisma.comment.update({ where: { id: commentId }, data: { content }, @@ -294,9 +301,7 @@ router } await prisma.comment.delete({ - where: { - id: commentId, - }, + where: { id: commentId }, }); res.sendStatus(204); From 1e79c48e0e6436cbe938e5e149bb64ebe935617d Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 12:56:21 +0900 Subject: [PATCH 32/36] =?UTF-8?q?[#M10]=20feat=20:=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=97=90=20transaction=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/articles.js | 69 +++++++++++++++++++++++----------------------- routes/images.js | 4 ++- routes/products.js | 68 +++++++++++++++++++++++---------------------- 3 files changed, 73 insertions(+), 68 deletions(-) diff --git a/routes/articles.js b/routes/articles.js index 881cb3f..09ce4f3 100644 --- a/routes/articles.js +++ b/routes/articles.js @@ -164,24 +164,24 @@ router.patch( res.status(400).send({ message: "이미 좋아요 처리된 게시글입니다." }); return; } - - await prisma.favorite.create({ - data: { - userId, - articleId, - }, - }); - - const updatedArticle = await prisma.article.update({ - where: { - id: articleId, - }, - data: { - likeCount: { - increment: 1, + const [createdFavorite, updatedArticle] = await prisma.$transaction([ + prisma.favorite.create({ + data: { + userId, + articleId, }, - }, - }); + }), + prisma.article.update({ + where: { + id: articleId, + }, + data: { + likeCount: { + increment: 1, + }, + }, + }), + ]); res.send(updatedArticle); }) @@ -208,25 +208,26 @@ router.patch( return; } - await prisma.favorite.delete({ - where: { - userId_articleId: { - userId, - articleId, + const [deletedFavorite, updatedArticle] = await prisma.$transaction([ + prisma.favorite.delete({ + where: { + userId_articleId: { + userId, + articleId, + }, }, - }, - }); - - const updatedArticle = await prisma.article.update({ - where: { - id: articleId, - }, - data: { - likeCount: { - decrement: 1, + }), + prisma.article.update({ + where: { + id: articleId, }, - }, - }); + data: { + likeCount: { + decrement: 1, + }, + }, + }), + ]); res.send(updatedArticle); }) diff --git a/routes/images.js b/routes/images.js index 33dbcae..e3ff815 100644 --- a/routes/images.js +++ b/routes/images.js @@ -23,12 +23,14 @@ const upload = multer({ storage: storage }); // 이미지 업로드 router.post("/upload", authenticate, upload.single("image"), async (req, res) => { const file = req.file; - console.log(req); + if (!file) { return res.status(400).send("이미지 파일을 선택해주세요."); } + const imagePath = file.path; const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; + const image = await prisma.image.create({ data: { imagePath: imagePath, diff --git a/routes/products.js b/routes/products.js index eec8014..9c8e011 100644 --- a/routes/products.js +++ b/routes/products.js @@ -139,23 +139,24 @@ router.patch( return; } - await prisma.favorite.create({ - data: { - userId, - productId, - }, - }); - - const updatedProduct = await prisma.product.update({ - where: { - id: productId, - }, - data: { - favoriteCount: { - increment: 1, + const [createdFavorite, updatedProduct] = await prisma.$transaction([ + prisma.favorite.create({ + data: { + userId, + productId, }, - }, - }); + }), + prisma.product.update({ + where: { + id: productId, + }, + data: { + likeCount: { + increment: 1, + }, + }, + }), + ]); res.send(updatedProduct); }) @@ -181,25 +182,26 @@ router.patch( return res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); } - await prisma.favorite.delete({ - where: { - userId_productId: { - userId, - productId, + const [deletedFavorite, updatedProduct] = await prisma.$transaction([ + prisma.favorite.delete({ + where: { + userId_productId: { + userId, + productId, + }, }, - }, - }); - - const updatedProduct = await prisma.product.update({ - where: { - id: productId, - }, - data: { - favoriteCount: { - decrement: 1, + }), + prisma.product.update({ + where: { + id: productId, }, - }, - }); + data: { + likeCount: { + decrement: 1, + }, + }, + }), + ]); res.send(updatedProduct); }) From 3d46d255ba6dd8a9f6e8a92d5983b3818efd1167 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 13:33:31 +0900 Subject: [PATCH 33/36] =?UTF-8?q?[#M10]=20feat=20:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=AF=B8=EB=93=A4=EC=9B=A8?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 11 +++++++---- middlewares/errorHandler.js | 14 ++++++++++++++ utils/tokens.js | 6 ++++-- 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 middlewares/errorHandler.js diff --git a/app.js b/app.js index 7a94b64..cfbfcdb 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ import moment from "moment-timezone"; import morgan from "morgan"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; +import errorHandler from "./middlewares/errorHandler.js"; import articlesRouter from "./routes/articles.js"; import authRouter from "./routes/auth.js"; import imagesRouter from "./routes/images.js"; @@ -17,13 +18,11 @@ const app = express(); morgan.token("date", (req, res, tz) => { return moment().tz(tz).format("YYYY-MM-DD HH:mm:ss"); }); -// 커스텀 포맷 설정: 한국 시간대의 현재 시간을 포함 + const customFormat = ":method :url :status :res[content-length] - :response-time ms - :date[Asia/Seoul]"; -// 로그 파일 스트림 생성 const accessLogStream = fs.createWriteStream(path.join(__dirname, "access.log"), { flags: "a" }); -// morgan 미들웨어 설정 (로그 파일에 기록) app.use(morgan(customFormat, { stream: accessLogStream })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -33,6 +32,10 @@ app.use("/articles", articlesRouter); app.use("/images", imagesRouter); app.use("/auth", authRouter); -app.listen(3000, () => { +app.use(errorHandler); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { console.log("Server is running on port 3000"); }); diff --git a/middlewares/errorHandler.js b/middlewares/errorHandler.js new file mode 100644 index 0000000..50208e3 --- /dev/null +++ b/middlewares/errorHandler.js @@ -0,0 +1,14 @@ +const errorHandler = (err, req, res) => { + console.error(err.stack); + + const status = err.status || 500; + const message = err.message || "서버 오류가 발생했습니다."; + + res.status(status).json({ + error: { + message, + }, + }); +}; + +export default errorHandler; diff --git a/utils/tokens.js b/utils/tokens.js index 12d5d2e..4697c00 100644 --- a/utils/tokens.js +++ b/utils/tokens.js @@ -3,7 +3,9 @@ import jwt from "jsonwebtoken"; dotenv.config(); -const { JWT_SECRET, JWT_ACCESS_EXPIRES_IN, JWT_REFRESH_EXPIRES_IN } = process.env; +const JWT_SECRET = process.env.JWT_SECRET || "kingPanda"; +const JWT_ACCESS_EXPIRES_IN = process.env.JWT_ACCESS_EXPIRES_IN || "15m"; +const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d"; export const generateAccessToken = (user) => { return jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: JWT_ACCESS_EXPIRES_IN }); @@ -16,7 +18,7 @@ export const generateRefreshToken = (user) => { export const regenerateRefreshToken = (refreshToken) => { try { const decoded = jwt.verify(refreshToken, JWT_SECRET); - return jwt.sign({ userId: decoded.userId }, JWT_SECRET, { expiresIn: "7d" }); + return jwt.sign({ userId: decoded.userId }, JWT_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); } catch (error) { throw new Error("Invalid refresh token"); } From 500f34e3537832c308d0e6648aff17c153826a6e Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 14:58:14 +0900 Subject: [PATCH 34/36] =?UTF-8?q?[#M10]=20fix=20:=20MVC=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20CORS=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=EC=9B=A8=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 코드를 MVC 패턴으로 리팩토링 - 이미지 업로드 기능 테스트를 위해 CORS 미들웨어 추가 --- app.js | 11 +- controllers/articleController.js | 86 +++++++ controllers/commentController.js | 60 +++++ controllers/imageController.js | 13 + controllers/productController.js | 73 ++++++ http/articles.http | 20 +- http/products.http | 20 +- middlewares/errorHandler.js | 21 +- package-lock.json | 13 + package.json | 1 + routes/articleRoutes.js | 28 +++ routes/articles.js | 339 --------------------------- routes/{auth.js => authRoutes.js} | 0 routes/{images.js => imageRoutes.js} | 23 +- routes/productRoutes.js | 28 +++ routes/products.js | 313 ------------------------- services/articleService.js | 173 ++++++++++++++ services/commentService.js | 106 +++++++++ services/imageService.js | 21 ++ services/productService.js | 156 ++++++++++++ test.html | 35 ++- utils/errors.js | 12 + 22 files changed, 843 insertions(+), 709 deletions(-) create mode 100644 controllers/articleController.js create mode 100644 controllers/commentController.js create mode 100644 controllers/imageController.js create mode 100644 controllers/productController.js create mode 100644 routes/articleRoutes.js delete mode 100644 routes/articles.js rename routes/{auth.js => authRoutes.js} (100%) rename routes/{images.js => imageRoutes.js} (50%) create mode 100644 routes/productRoutes.js delete mode 100644 routes/products.js create mode 100644 services/articleService.js create mode 100644 services/commentService.js create mode 100644 services/imageService.js create mode 100644 services/productService.js create mode 100644 utils/errors.js diff --git a/app.js b/app.js index cfbfcdb..4232adc 100644 --- a/app.js +++ b/app.js @@ -1,3 +1,4 @@ +import cors from "cors"; import express from "express"; import fs from "fs"; import moment from "moment-timezone"; @@ -5,11 +6,10 @@ import morgan from "morgan"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; import errorHandler from "./middlewares/errorHandler.js"; -import articlesRouter from "./routes/articles.js"; -import authRouter from "./routes/auth.js"; -import imagesRouter from "./routes/images.js"; -import productsRouter from "./routes/products.js"; - +import articlesRouter from "./routes/articleRoutes.js"; +import authRouter from "./routes/authRoutes.js"; +import imagesRouter from "./routes/imageRoutes.js"; +import productsRouter from "./routes/productRoutes.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -26,6 +26,7 @@ const accessLogStream = fs.createWriteStream(path.join(__dirname, "access.log"), app.use(morgan(customFormat, { stream: accessLogStream })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use(cors()); app.use("/products", productsRouter); app.use("/articles", articlesRouter); diff --git a/controllers/articleController.js b/controllers/articleController.js new file mode 100644 index 0000000..ea6b2bd --- /dev/null +++ b/controllers/articleController.js @@ -0,0 +1,86 @@ +import { assert } from "superstruct"; +import * as articleService from "../services/articleService.js"; +import { CreateArticle, PatchArticle } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; +import AppError from "../utils/errors.js"; + +export const getArticles = asyncHandler(async (req, res) => { + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const articles = await articleService.getArticles({ offset, limit, orderBy, keyword }); + const bestArticles = await articleService.getBestArticles(); + res.send({ articles, bestArticles }); +}); + +export const createArticle = asyncHandler(async (req, res) => { + assert(req.body, CreateArticle); + const { userId } = req; + const article = await articleService.createArticle({ ...req.body, userId }); + res.status(201).send(article); +}); + +export const getArticleById = asyncHandler(async (req, res) => { + const { id } = req.params; + const article = await articleService.getArticleById(id); + res.send(article); +}); + +export const updateArticle = asyncHandler(async (req, res, next) => { + assert(req.body, PatchArticle); + + const { id: articleId } = req.params; + const { userId } = req; + + try { + const updatedArticle = await articleService.updateArticle(articleId, userId, req.body); + res.send(updatedArticle); + } catch (error) { + if (error instanceof AppError) { + return res.status(error.statusCode).json({ message: error.message }); + } + } +}); + +export const deleteArticle = asyncHandler(async (req, res, next) => { + const { id: articleId } = req.params; + const { userId } = req; + + try { + await articleService.deleteArticle(articleId, userId); + res.sendStatus(204); + } catch (error) { + if (error instanceof AppError) { + return res.status(error.statusCode).json({ message: error.message }); + } + next(error); + } +}); + +export const likeArticle = asyncHandler(async (req, res, next) => { + const { id: articleId } = req.params; + const { userId } = req; + + try { + const updatedArticle = await articleService.likeArticle(articleId, userId); + res.send(updatedArticle); + } catch (error) { + if (error instanceof AppError) { + return res.status(error.statusCode).json({ message: error.message }); + } + next(error); + } +}); + +export const unlikeArticle = asyncHandler(async (req, res, next) => { + const { id: articleId } = req.params; + const { userId } = req; + + try { + const updatedArticle = await articleService.unlikeArticle(articleId, userId); + res.send(updatedArticle); + } catch (error) { + if (error instanceof AppError) { + return res.status(error.statusCode).json({ message: error.message }); + } + next(error); + } +}); diff --git a/controllers/commentController.js b/controllers/commentController.js new file mode 100644 index 0000000..f73fc09 --- /dev/null +++ b/controllers/commentController.js @@ -0,0 +1,60 @@ +import { assert } from "superstruct"; +import * as commentService from "../services/commentService.js"; +import { CreateComment, PatchComment } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +export const getCommentsByProductId = asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { cursor } = req.query; + const comments = await commentService.getCommentsByProductId(productId, cursor); + res.send(comments); +}); + +export const getCommentsByArticleId = asyncHandler(async (req, res) => { + const { id: articleId } = req.params; + const { cursor } = req.query; + const comments = await commentService.getCommentsByArticleId(articleId, cursor); + res.send(comments); +}); + +export const createComment = asyncHandler(async (req, res) => { + assert(req.body, CreateComment); + + const { userId } = req; + + const commentData = { + ...req.body, + ...req.params, + userId, + }; + + const comment = await commentService.createComment(commentData); + res.status(201).send(comment); +}); + +export const updateComment = asyncHandler(async (req, res) => { + assert(req.body, PatchComment); + + const { userId } = req; + const { content } = req.body; + const { commentId } = req.params; + + try { + const updatedComment = await commentService.updateComment(commentId, userId, content); + res.send(updatedComment); + } catch (error) { + res.status(403).json({ message: error.message }); + } +}); + +export const deleteComment = asyncHandler(async (req, res) => { + const { commentId } = req.params; + const { userId } = req; + + try { + await commentService.deleteComment(commentId, userId); + res.sendStatus(204); + } catch (error) { + res.status(403).json({ message: error.message }); + } +}); diff --git a/controllers/imageController.js b/controllers/imageController.js new file mode 100644 index 0000000..76a3bd5 --- /dev/null +++ b/controllers/imageController.js @@ -0,0 +1,13 @@ +import * as imageService from "../services/imageService.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +export const uploadImage = asyncHandler(async (req, res) => { + const file = req.file; + + try { + const imageUrl = await imageService.uploadImage(file); + res.status(200).json({ url: imageUrl }); + } catch (error) { + res.status(400).send(error.message); + } +}); diff --git a/controllers/productController.js b/controllers/productController.js new file mode 100644 index 0000000..9467511 --- /dev/null +++ b/controllers/productController.js @@ -0,0 +1,73 @@ +import { assert } from "superstruct"; +import * as productService from "../services/productService.js"; +import { CreateProduct, PatchProduct } from "../structs.js"; +import asyncHandler from "../utils/asyncHandler.js"; + +export const getProducts = asyncHandler(async (req, res) => { + const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; + const products = await productService.getProducts({ offset, limit, orderBy, keyword }); + res.send(products); +}); + +export const createProduct = asyncHandler(async (req, res) => { + assert(req.body, CreateProduct); + const { userId } = req; + const product = await productService.createProduct({ ...req.body, userId }); + res.status(201).send(product); +}); + +export const getProductById = asyncHandler(async (req, res) => { + const { id } = req.params; + const product = await productService.getProductById(id); + res.send(product); +}); + +export const updateProduct = asyncHandler(async (req, res) => { + assert(req.body, PatchProduct); + + const { id: productId } = req.params; + const { userId } = req; + + try { + const updatedProduct = await productService.updateProduct(productId, userId, req.body); + res.send(updatedProduct); + } catch (error) { + res.status(403).json({ message: error.message }); + } +}); + +export const deleteProduct = asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; + + try { + await productService.deleteProduct(productId, userId); + res.sendStatus(204); + } catch (error) { + res.status(403).json({ message: error.message }); + } +}); + +export const likeProduct = asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; + + try { + const updatedProduct = await productService.likeProduct(productId, userId); + res.send(updatedProduct); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}); + +export const unlikeProduct = asyncHandler(async (req, res) => { + const { id: productId } = req.params; + const { userId } = req; + + try { + const updatedProduct = await productService.unlikeProduct(productId, userId); + res.send(updatedProduct); + } catch (error) { + res.status(400).json({ message: error.message }); + } +}); diff --git a/http/articles.http b/http/articles.http index e7add53..99e2a4a 100644 --- a/http/articles.http +++ b/http/articles.http @@ -9,7 +9,7 @@ GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 등록 POST http://localhost:3000/articles -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -20,8 +20,8 @@ Content-Type: application/json ### # 게시글 수정 -PATCH http://localhost:3000/articles/9c96039f-e56d-470e-847f-aa24b8da981c -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +PATCH http://localhost:3000/articles/c058e3ae-00d2-4b5a-a0dc-acd284f03e7b +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -32,17 +32,17 @@ Content-Type: application/json ### # 게시글 삭제 DELETE http://localhost:3000/articles/9c96039f-e56d-470e-847f-aa24b8da981c -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 ### # 게시글 좋아요 PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/like -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 ### # 게시글 좋아요 취소 PATCH http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da/unlike -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 @@ -59,7 +59,7 @@ GET http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments # 자유게시판 댓글 등록 POST http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 { "content":"판다가 너무 귀여워요2" @@ -67,9 +67,9 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhd ### # 자유게시판 댓글 수정 -PATCH http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/98b65e20-e12b-43fb-8b04-6525b019eb8b +PATCH http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/31599931-2aac-47fb-85ad-8de4c0a4a56d Content-Type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 { "content":"판다가 너무 귀여워요 수정" @@ -78,4 +78,4 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhd ### # 자유게시판 댓글 삭제 DELETE http://localhost:3000/articles/287cb4c8-48c5-49e1-82fa-a1b9e2d7b4e3/comments/98b65e20-e12b-43fb-8b04-6525b019eb8b -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NzYyMSwiZXhwIjoxNzE2ODY4NTIxfQ.fLV77AhiNJ_mlIzQ_dc5qv8v2gxth6_LKxSrUv3NXYQ \ No newline at end of file +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 \ No newline at end of file diff --git a/http/products.http b/http/products.http index 315fe1a..39b37f6 100644 --- a/http/products.http +++ b/http/products.http @@ -13,12 +13,12 @@ GET http://localhost:3000/products?keyword=판다 ### # 상품 상세 조회 -GET http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 +GET http://localhost:3000/products/d44e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 ### # 상품 등록 POST http://localhost:3000/products -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -32,7 +32,7 @@ Content-Type: application/json ### # 상품 수정 PATCH http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTMyNSwiZXhwIjoxNzE2ODY2MjI1fQ.yHZTsbdl-ZWTUM0_5RTe6wt2mAgF11uLTmN0MuLda9U +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -45,17 +45,17 @@ Content-Type: application/json ### # 상품 삭제 DELETE http://localhost:3000/products/d4e8c9a0-5d45-4c9f-9b4b-7626f3c9c9a9 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTMyNSwiZXhwIjoxNzE2ODY2MjI1fQ.yHZTsbdl-ZWTUM0_5RTe6wt2mAgF11uLTmN0MuLda9U +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 ### # 상품 좋아요 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/like -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NjY3NiwiZXhwIjoxNzE2ODY3NTc2fQ.TiC1uKaRL28Y1WM-ADHCzo0Ply-l_IfYZx9e_rjyEqI +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 ### # 상품 좋아요 취소 PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/unlike -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgsImlhdCI6MTcxNjc5NDc3MCwiZXhwIjoxNzE2Nzk1NjcwfQ.4jHyaxo0BgeSCSfTGAwnTlMLyRZGEJPoEOqUe-Xdo9w +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 ### # 상품 댓글 조회 @@ -68,7 +68,7 @@ GET http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments ### # 상품 댓글 등록 POST http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -78,8 +78,8 @@ Content-Type: application/json ### # 상품 댓글 수정 -PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/9d7d8a72-693e-44d3-bf72-889c9ebac0d9 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NTcxNywiZXhwIjoxNzE2ODY2NjE3fQ.1zbdGjqysDNVmAGnkd-FWEt8jOUQQ58FoyqMryklj3E +PATCH http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/536a7b1b-83c9-42c1-93b9-c4755e7fc8d6 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 Content-Type: application/json { @@ -89,4 +89,4 @@ Content-Type: application/json ### # 상품 댓글 삭제 DELETE http://localhost:3000/products/377ce06c-23c8-46de-b86f-2cfd43d41cbc/comments/9d7d8a72-693e-44d3-bf72-889c9ebac0d9 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg2NjY3NiwiZXhwIjoxNzE2ODY3NTc2fQ.TiC1uKaRL28Y1WM-ADHCzo0Ply-l_IfYZx9e_rjyEqI \ No newline at end of file +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 \ No newline at end of file diff --git a/middlewares/errorHandler.js b/middlewares/errorHandler.js index 50208e3..e042938 100644 --- a/middlewares/errorHandler.js +++ b/middlewares/errorHandler.js @@ -1,14 +1,19 @@ +import AppError from "../utils/errors.js"; + const errorHandler = (err, req, res) => { console.error(err.stack); - const status = err.status || 500; - const message = err.message || "서버 오류가 발생했습니다."; - - res.status(status).json({ - error: { - message, - }, - }); + if (err instanceof AppError) { + res.status(err.statusCode).json({ + status: err.status, + message: err.message, + }); + } else { + res.status(500).json({ + status: "error", + message: "서버 오류가 발생했습니다.", + }); + } }; export default errorHandler; diff --git a/package-lock.json b/package-lock.json index 09bccb8..880fe62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@prisma/client": "^5.4.2", "bcrypt": "^5.1.1", + "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "is-email": "^1.0.2", @@ -439,6 +440,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 87a3db6..7334f69 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "dependencies": { "@prisma/client": "^5.4.2", "bcrypt": "^5.1.1", + "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "is-email": "^1.0.2", diff --git a/routes/articleRoutes.js b/routes/articleRoutes.js new file mode 100644 index 0000000..bfba509 --- /dev/null +++ b/routes/articleRoutes.js @@ -0,0 +1,28 @@ +import express from "express"; +import * as articleController from "../controllers/articleController.js"; +import * as commentController from "../controllers/commentController.js"; +import authenticate from "../middlewares/authenticate.js"; +const router = express.Router(); +router.route("/").get(articleController.getArticles).post(authenticate, articleController.createArticle); + +router + .route("/:id") + .get(articleController.getArticleById) + .patch(authenticate, articleController.updateArticle) + .delete(authenticate, articleController.deleteArticle); + +router.route("/:id/like").patch(authenticate, articleController.likeArticle); + +router.route("/:id/unlike").patch(authenticate, articleController.unlikeArticle); + +router + .route("/:articleId/comments") + .get(commentController.getCommentsByProductId) + .post(authenticate, commentController.createComment); + +router + .route("/:articleId/comments/:commentId") + .patch(authenticate, commentController.updateComment) + .delete(authenticate, commentController.deleteComment); + +export default router; diff --git a/routes/articles.js b/routes/articles.js deleted file mode 100644 index 09ce4f3..0000000 --- a/routes/articles.js +++ /dev/null @@ -1,339 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import express from "express"; -import { assert } from "superstruct"; -import authenticate from "../middlewares/authenticate.js"; -import { CreateArticle, CreateComment, PatchArticle, PatchComment } from "../structs.js"; -import asyncHandler from "../utils/asyncHandler.js"; - -const prisma = new PrismaClient(); -const router = express.Router(); - -router - .route("/") - .get( - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 like, recent (기본값: recent) - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; - const articles = await prisma.article.findMany({ - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - writer: true, - }, - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - title: { - contains: keyword, - mode: "insensitive", - }, - }, - { - content: { - contains: keyword, - mode: "insensitive", - }, - }, - ], - }, - }); - // 좋아요가 많은 상위 4개의 글 조회 - const bestArticles = await prisma.article.findMany({ - orderBy: { - likeCount: "desc", - }, - take: 4, - }); - - res.send({ articles, bestArticles }); - }) - ) - .post( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, CreateArticle); - const { userId } = req; - const article = await prisma.article.create({ - data: { ...req.body, userId }, - }); - res.status(201).send(article); - }) - ); - -router - .route("/:id") - .get( - asyncHandler(async (req, res) => { - const { id } = req.params; - const article = await prisma.article.findUniqueOrThrow({ - where: { - id, - }, - select: { - id: true, - title: true, - content: true, - imageUrl: true, - createdAt: true, - likeCount: true, - writer: true, - }, - }); - res.send(article); - }) - ) - .patch( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, PatchArticle); - - const { id: articleId } = req.params; - const { userId } = req; - - const article = await prisma.article.findUniqueOrThrow({ - where: { id: articleId }, - }); - - if (article.userId !== userId) { - return res.status(403).json({ error: "게시글을 수정할 권한이 없습니다." }); - } - - const updatedArticle = await prisma.article.update({ - where: { - id: articleId, - }, - data: req.body, - }); - - res.send(updatedArticle); - }) - ) - .delete( - authenticate, - asyncHandler(async (req, res) => { - const { id: articleId } = req.params; - const { userId } = req; - - const article = await prisma.article.findUniqueOrThrow({ - where: { id: articleId }, - }); - - if (article.userId !== userId) { - return res.status(403).json({ error: "게시글을 삭제할 권한이 없습니다." }); - } - - await prisma.article.delete({ - where: { - id: articleId, - }, - }); - - res.sendStatus(204); - }) - ); - -router.patch( - "/:id/like", - authenticate, - asyncHandler(async (req, res) => { - const { id: articleId } = req.params; - const { userId } = req; - - const favorite = await prisma.favorite.findUnique({ - where: { - userId_articleId: { - userId, - articleId, - }, - }, - }); - - if (favorite) { - res.status(400).send({ message: "이미 좋아요 처리된 게시글입니다." }); - return; - } - const [createdFavorite, updatedArticle] = await prisma.$transaction([ - prisma.favorite.create({ - data: { - userId, - articleId, - }, - }), - prisma.article.update({ - where: { - id: articleId, - }, - data: { - likeCount: { - increment: 1, - }, - }, - }), - ]); - - res.send(updatedArticle); - }) -); - -router.patch( - "/:id/unlike", - authenticate, - asyncHandler(async (req, res) => { - const { id: articleId } = req.params; - const { userId } = req; - - const favorite = await prisma.favorite.findUnique({ - where: { - userId_articleId: { - userId, - articleId, - }, - }, - }); - - if (!favorite) { - res.status(400).send({ message: "아직 좋아요 처리되지 않은 게시글입니다." }); - return; - } - - const [deletedFavorite, updatedArticle] = await prisma.$transaction([ - prisma.favorite.delete({ - where: { - userId_articleId: { - userId, - articleId, - }, - }, - }), - prisma.article.update({ - where: { - id: articleId, - }, - data: { - likeCount: { - decrement: 1, - }, - }, - }), - ]); - - res.send(updatedArticle); - }) -); - -router - .route("/:id/comments") - .get( - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - articleId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, - }, - skip: 1, - }; - } - - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) - ) - .post( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - const { userId } = req; - const { id: articleId } = req.params; - - const comment = await prisma.comment.create({ - data: { - ...req.body, - articleId, - userId, - }, - }); - res.status(201).send(comment); - }) - ); - -router - .route("/:id/comments/:commentId") - .patch( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - - const { userId } = req; - const { content } = req.body; - const { commentId } = req.params; - - const comment = await prisma.comment.findUniqueOrThrow({ - where: { id: commentId }, - }); - - if (comment.userId !== userId) { - return res.status(403).json({ error: "이 댓글을 수정할 권한이 없습니다." }); - } - - const updatedComment = await prisma.comment.update({ - where: { id: commentId }, - data: { content }, - }); - - res.send(updatedComment); - }) - ) - .delete( - authenticate, - asyncHandler(async (req, res) => { - const { commentId } = req.params; - const { userId } = req; - - const comment = await prisma.comment.findUniqueOrThrow({ - where: { id: commentId }, - }); - - if (comment.userId !== userId) { - return res.status(403).json({ error: "이 댓글을 삭제할 권한이 없습니다." }); - } - - await prisma.comment.delete({ - where: { id: commentId }, - }); - - res.sendStatus(204); - }) - ); - -export default router; diff --git a/routes/auth.js b/routes/authRoutes.js similarity index 100% rename from routes/auth.js rename to routes/authRoutes.js diff --git a/routes/images.js b/routes/imageRoutes.js similarity index 50% rename from routes/images.js rename to routes/imageRoutes.js index e3ff815..f413c71 100644 --- a/routes/images.js +++ b/routes/imageRoutes.js @@ -1,10 +1,8 @@ -import { PrismaClient } from "@prisma/client"; import express from "express"; import multer from "multer"; import path from "path"; +import * as imageController from "../controllers/imageController.js"; import authenticate from "../middlewares/authenticate.js"; -const prisma = new PrismaClient(); -const SERVER_URL = "http://localhost:3000"; const router = express.Router(); @@ -21,23 +19,6 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage }); // 이미지 업로드 -router.post("/upload", authenticate, upload.single("image"), async (req, res) => { - const file = req.file; - - if (!file) { - return res.status(400).send("이미지 파일을 선택해주세요."); - } - - const imagePath = file.path; - const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; - - const image = await prisma.image.create({ - data: { - imagePath: imagePath, - }, - }); - - res.status(200).json({ url: imageUrl }); -}); +router.post("/upload", authenticate, upload.single("image"), imageController.uploadImage); export default router; diff --git a/routes/productRoutes.js b/routes/productRoutes.js new file mode 100644 index 0000000..49386ad --- /dev/null +++ b/routes/productRoutes.js @@ -0,0 +1,28 @@ +import express from "express"; +import * as commentController from "../controllers/commentController.js"; +import * as productController from "../controllers/productController.js"; +import authenticate from "../middlewares/authenticate.js"; +const router = express.Router(); + +router.route("/").get(productController.getProducts).post(authenticate, productController.createProduct); + +router + .route("/:id") + .get(productController.getProductById) + .patch(authenticate, productController.updateProduct) + .delete(authenticate, productController.deleteProduct); + +router.route("/:id/like").patch(authenticate, productController.likeProduct); + +router.route("/:id/unlike").patch(authenticate, productController.unlikeProduct); +router + .route("/:productId/comments") + .get(commentController.getCommentsByProductId) + .post(authenticate, commentController.createComment); + +router + .route("/:productId/comments/:commentId") + .patch(authenticate, commentController.updateComment) + .delete(authenticate, commentController.deleteComment); + +export default router; diff --git a/routes/products.js b/routes/products.js deleted file mode 100644 index 9c8e011..0000000 --- a/routes/products.js +++ /dev/null @@ -1,313 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import express from "express"; -import { assert } from "superstruct"; -import authenticate from "../middlewares/authenticate.js"; -import { CreateComment, CreateProduct, PatchComment, PatchProduct } from "../structs.js"; -import asyncHandler from "../utils/asyncHandler.js"; - -const prisma = new PrismaClient(); -const router = express.Router(); -router - .route("/") - .get( - asyncHandler(async (req, res) => { - /** - * 쿼리 파라미터 - * - offset : 가져올 데이터의 시작 지점 - * - limit : 한 번에 가져올 데이터의 개수 - * - orderBy : 정렬 기준 favorite, recent (기본값: recent) - * - keyword : 검색 키워드 - */ - const { offset = 0, limit = 10, orderBy = "recent", keyword = "" } = req.query; - const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; - const products = await prisma.product.findMany({ - orderBy: order, - skip: parseInt(offset), - take: parseInt(limit), - where: { - OR: [ - { - name: { - contains: keyword, - mode: "insensitive", - }, - }, - { - description: { - contains: keyword, - mode: "insensitive", - }, - }, - ], - }, - }); - res.send(products); - }) - ) - .post( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, CreateProduct); - const { userId } = req; - const product = await prisma.product.create({ - data: { ...req.body, userId }, - }); - res.status(201).send(product); - }) - ); - -router - .route("/:id") - .get( - asyncHandler(async (req, res) => { - const { id } = req.params; - const product = await prisma.product.findUniqueOrThrow({ - where: { - id, - }, - }); - res.send(product); - }) - ) - .patch( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, PatchProduct); - - const { id: productId } = req.params; - const { userId } = req; - - const product = await prisma.product.findUniqueOrThrow({ - where: { id: productId }, - }); - - if (product.userId !== userId) { - return res.status(403).json({ error: "상품을 수정할 권한이 없습니다." }); - } - - const updatedProduct = await prisma.product.update({ - where: { - id: productId, - }, - data: req.body, - }); - - res.send(updatedProduct); - }) - ) - .delete( - authenticate, - asyncHandler(async (req, res) => { - const { id: productId } = req.params; - const { userId } = req; - - const product = await prisma.product.findUniqueOrThrow({ - where: { id: productId }, - }); - - if (product.userId !== userId) { - return res.status(403).json({ error: "상품을 삭제할 권한이 없습니다." }); - } - await prisma.product.delete({ - where: { - id: productId, - }, - }); - - res.sendStatus(204); - }) - ); - -router.patch( - "/:id/like", - authenticate, - asyncHandler(async (req, res) => { - const { id: productId } = req.params; - const { userId } = req; - - const favorite = await prisma.favorite.findUnique({ - where: { - userId_productId: { - userId, - productId, - }, - }, - }); - - if (favorite) { - res.status(400).send({ message: "이미 좋아요 처리된 상품입니다." }); - return; - } - - const [createdFavorite, updatedProduct] = await prisma.$transaction([ - prisma.favorite.create({ - data: { - userId, - productId, - }, - }), - prisma.product.update({ - where: { - id: productId, - }, - data: { - likeCount: { - increment: 1, - }, - }, - }), - ]); - - res.send(updatedProduct); - }) -); - -router.patch( - "/:id/unlike", - authenticate, - asyncHandler(async (req, res) => { - const { id: productId } = req.params; - const { userId } = req; - - const favorite = await prisma.favorite.findUnique({ - where: { - userId_productId: { - userId, - productId, - }, - }, - }); - - if (!favorite) { - return res.status(400).send({ message: "아직 좋아요 처리되지 않은 상품입니다." }); - } - - const [deletedFavorite, updatedProduct] = await prisma.$transaction([ - prisma.favorite.delete({ - where: { - userId_productId: { - userId, - productId, - }, - }, - }), - prisma.product.update({ - where: { - id: productId, - }, - data: { - likeCount: { - decrement: 1, - }, - }, - }), - ]); - - res.send(updatedProduct); - }) -); - -router - .route("/:id/comments") - .get( - asyncHandler(async (req, res) => { - const { id } = req.params; - const { cursor } = req.query; - let queryOptions = { - take: 10, - orderBy: { - createdAt: "desc", - }, - where: { - productId: id, - }, - select: { - id: true, - content: true, - createdAt: true, - writer: true, - }, - }; - - if (cursor) { - queryOptions = { - ...queryOptions, - cursor: { - id: cursor, - }, - skip: 1, - }; - } - - const comments = await prisma.comment.findMany(queryOptions); - res.send(comments); - }) - ) - .post( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, CreateComment); - - const { userId } = req; - const { id: productId } = req.params; - - const comment = await prisma.comment.create({ - data: { - ...req.body, - productId, - userId, - }, - }); - res.status(201).send(comment); - }) - ); - -router - .route("/:id/comments/:commentId") - .patch( - authenticate, - asyncHandler(async (req, res) => { - assert(req.body, PatchComment); - - const { userId } = req; - const { content } = req.body; - const { commentId } = req.params; - - const comment = await prisma.comment.findUniqueOrThrow({ - where: { id: commentId }, - }); - - if (comment.userId !== userId) { - return res.status(403).json({ error: "이 댓글을 수정할 권한이 없습니다." }); - } - - const updatedComment = await prisma.comment.update({ - where: { id: commentId }, - data: { content }, - }); - res.send(updatedComment); - }) - ) - .delete( - authenticate, - asyncHandler(async (req, res) => { - const { commentId } = req.params; - const { userId } = req; - - const comment = await prisma.comment.findUniqueOrThrow({ - where: { id: commentId }, - }); - - if (comment.userId !== userId) { - return res.status(403).json({ error: "이 댓글을 삭제할 권한이 없습니다." }); - } - - await prisma.comment.delete({ - where: { id: commentId }, - }); - - res.sendStatus(204); - }) - ); - -export default router; diff --git a/services/articleService.js b/services/articleService.js new file mode 100644 index 0000000..3832800 --- /dev/null +++ b/services/articleService.js @@ -0,0 +1,173 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export const getArticles = async ({ offset, limit, orderBy, keyword }) => { + const order = orderBy === "like" ? { likeCount: "desc" } : { createdAt: "desc" }; + const articles = await prisma.article.findMany({ + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + writer: true, + }, + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + title: { + contains: keyword, + mode: "insensitive", + }, + }, + { + content: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); + + return articles; +}; + +export const getBestArticles = async () => { + const bestArticles = await prisma.article.findMany({ + orderBy: { + likeCount: "desc", + }, + take: 4, + }); + + return bestArticles; +}; + +export const createArticle = async (articleData) => { + return await prisma.article.create({ + data: articleData, + }); +}; + +export const getArticleById = async (id) => { + return await prisma.article.findUniqueOrThrow({ + where: { id }, + select: { + id: true, + title: true, + content: true, + imageUrl: true, + createdAt: true, + likeCount: true, + writer: true, + }, + }); +}; + +export const updateArticle = async (articleId, userId, articleData) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { id: articleId }, + }); + + if (article.userId !== userId) { + throw new AppError("게시글을 수정할 권한이 없습니다.", 403); + } + + return await prisma.article.update({ + where: { id: articleId }, + data: articleData, + }); +}; + +export const deleteArticle = async (articleId, userId) => { + const article = await prisma.article.findUniqueOrThrow({ + where: { id: articleId }, + }); + + if (article.userId !== userId) { + throw new AppError("게시글을 삭제할 권한이 없습니다.", 403); + } + + await prisma.article.delete({ + where: { id: articleId }, + }); +}; + +export const likeArticle = async (articleId, userId) => { + const favorite = await prisma.favorite.findUnique({ + where: { + userId_articleId: { + userId, + articleId, + }, + }, + }); + + if (favorite) { + throw new AppError("이미 좋아요 처리된 게시글입니다.", 409); + } + + const [createdFavorite, updatedArticle] = await prisma.$transaction([ + prisma.favorite.create({ + data: { + userId, + articleId, + }, + }), + prisma.article.update({ + where: { + id: articleId, + }, + data: { + likeCount: { + increment: 1, + }, + }, + }), + ]); + + return updatedArticle; +}; + +export const unlikeArticle = async (articleId, userId) => { + const favorite = await prisma.favorite.findUnique({ + where: { + userId_articleId: { + userId, + articleId, + }, + }, + }); + + if (!favorite) { + throw new AppError("아직 좋아요 처리되지 않은 게시글입니다.", 400); + } + + const [deletedFavorite, updatedArticle] = await prisma.$transaction([ + prisma.favorite.delete({ + where: { + userId_articleId: { + userId, + articleId, + }, + }, + }), + prisma.article.update({ + where: { + id: articleId, + }, + data: { + likeCount: { + decrement: 1, + }, + }, + }), + ]); + + return updatedArticle; +}; diff --git a/services/commentService.js b/services/commentService.js new file mode 100644 index 0000000..68df378 --- /dev/null +++ b/services/commentService.js @@ -0,0 +1,106 @@ +import { PrismaClient } from "@prisma/client"; +import AppError from "../utils/errors.js"; + +const prisma = new PrismaClient(); + +export const getCommentsByProductId = async (productId, cursor) => { + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + productId: productId, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + return await prisma.comment.findMany(queryOptions); +}; + +export const getCommentsByArticleId = async (articleId, cursor) => { + let queryOptions = { + take: 10, + orderBy: { + createdAt: "desc", + }, + where: { + articleId: articleId, + }, + select: { + id: true, + content: true, + createdAt: true, + writer: true, + }, + }; + + if (cursor) { + queryOptions = { + ...queryOptions, + cursor: { + id: cursor, + }, + skip: 1, + }; + } + + return await prisma.comment.findMany(queryOptions); +}; + +export const createComment = async (commentData) => { + return await prisma.comment.create({ + data: commentData, + }); +}; + +export const updateComment = async (commentId, userId, content) => { + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!comment) { + throw new AppError("존재하지 않는 댓글입니다.", 404); + } + + if (comment.userId !== userId) { + throw new AppError("이 댓글을 수정할 권한이 없습니다.", 403); + } + + return await prisma.comment.update({ + where: { id: commentId }, + data: { content }, + }); +}; + +export const deleteComment = async (commentId, userId) => { + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!comment) { + throw new AppError("존재하지 않는 댓글입니다.", 404); + } + + if (comment.userId !== userId) { + throw new AppError("이 댓글을 삭제할 권한이 없습니다.", 403); + } + await prisma.comment.delete({ + where: { id: commentId }, + }); +}; diff --git a/services/imageService.js b/services/imageService.js new file mode 100644 index 0000000..0950480 --- /dev/null +++ b/services/imageService.js @@ -0,0 +1,21 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); +const SERVER_URL = "http://localhost:3000"; + +export const uploadImage = async (file) => { + if (!file) { + throw new Error("이미지 파일을 선택해주세요."); + } + + const imagePath = file.path; + const imageUrl = `${SERVER_URL}/${imagePath.replace(/\\/g, "/")}`; + + const image = await prisma.image.create({ + data: { + imagePath: imagePath, + }, + }); + + return imageUrl; +}; diff --git a/services/productService.js b/services/productService.js new file mode 100644 index 0000000..e8a3b7a --- /dev/null +++ b/services/productService.js @@ -0,0 +1,156 @@ +import { PrismaClient } from "@prisma/client"; +import AppError from "../utils/errors.js"; +const prisma = new PrismaClient(); + +export const getProducts = async ({ offset, limit, orderBy, keyword }) => { + const order = orderBy === "favorite" ? { favoriteCount: "desc" } : { createdAt: "desc" }; + return await prisma.product.findMany({ + orderBy: order, + skip: parseInt(offset), + take: parseInt(limit), + where: { + OR: [ + { + name: { + contains: keyword, + mode: "insensitive", + }, + }, + { + description: { + contains: keyword, + mode: "insensitive", + }, + }, + ], + }, + }); +}; + +export const createProduct = async (productData) => { + return await prisma.product.create({ + data: productData, + }); +}; + +export const getProductById = async (id) => { + const product = await prisma.product.findUnique({ + where: { id }, + }); + + if (!product) { + throw new AppError("존재하지 않는 상품입니다.", 404); + } + return product; +}; + +export const updateProduct = async (productId, userId, productData) => { + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + throw new AppError("존재하지 않는 상품입니다.", 404); + } + + if (product.userId !== userId) { + throw new AppError("상품을 수정할 권한이 없습니다.", 403); + } + + return await prisma.product.update({ + where: { id: productId }, + data: productData, + }); +}; + +export const deleteProduct = async (productId, userId) => { + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + throw new AppError("존재하지 않는 상품입니다.", 404); + } + + if (product.userId !== userId) { + throw new AppError("상품을 삭제할 권한이 없습니다.", 403); + } + + await prisma.product.delete({ + where: { id: productId }, + }); +}; + +export const likeProduct = async (productId, userId) => { + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }); + + if (favorite) { + throw AppError("이미 좋아요 처리된 상품입니다.", 409); + } + + const [createdFavorite, updatedProduct] = await prisma.$transaction([ + prisma.favorite.create({ + data: { + userId, + productId, + }, + }), + prisma.product.update({ + where: { + id: productId, + }, + data: { + favoriteCount: { + increment: 1, + }, + }, + }), + ]); + + return updatedProduct; +}; + +export const unlikeProduct = async (productId, userId) => { + const favorite = await prisma.favorite.findUnique({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }); + + if (!favorite) { + throw AppError("아직 좋아요 처리되지 않은 상품입니다.", 409); + } + + const [deletedFavorite, updatedProduct] = await prisma.$transaction([ + prisma.favorite.delete({ + where: { + userId_productId: { + userId, + productId, + }, + }, + }), + prisma.product.update({ + where: { + id: productId, + }, + data: { + favoriteCount: { + decrement: 1, + }, + }, + }), + ]); + + return updatedProduct; +}; diff --git a/test.html b/test.html index 55b802b..4e32f08 100644 --- a/test.html +++ b/test.html @@ -4,14 +4,43 @@ - Document + Upload Image -
+ - +
+ + \ No newline at end of file diff --git a/utils/errors.js b/utils/errors.js new file mode 100644 index 0000000..c3a6992 --- /dev/null +++ b/utils/errors.js @@ -0,0 +1,12 @@ +class AppError extends Error { + constructor(message, statusCode) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith("4") ? "fail" : "error"; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } +} + +export default AppError; From 520f7512d61ea556fe7527f610191faf95928790 Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Tue, 28 May 2024 15:55:48 +0900 Subject: [PATCH 35/36] =?UTF-8?q?[#M10]=20feat:=20=EA=B5=AC=EA=B8=80=20OAu?= =?UTF-8?q?th=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구글 OAuth 로그인 후 JWT 토큰 생성 구현 - 유저 기능에 MVC 패턴 적용 --- app.js | 5 + config/passport.js | 59 ++++++++ controllers/authController.js | 74 ++++++++++ middlewares/errorHandler.js | 6 +- package-lock.json | 136 ++++++++++++++++++ package.json | 3 + .../migration.sql | 11 ++ .../migration.sql | 2 + prisma/schema.prisma | 3 +- routes/authRoutes.js | 99 ++----------- services/authService.js | 32 +++++ 11 files changed, 338 insertions(+), 92 deletions(-) create mode 100644 config/passport.js create mode 100644 controllers/authController.js create mode 100644 prisma/migrations/20240528063148_add_google_auth/migration.sql create mode 100644 prisma/migrations/20240528063710_make_password_optional/migration.sql create mode 100644 services/authService.js diff --git a/app.js b/app.js index 4232adc..47c0734 100644 --- a/app.js +++ b/app.js @@ -1,10 +1,12 @@ import cors from "cors"; import express from "express"; +import session from "express-session"; import fs from "fs"; import moment from "moment-timezone"; import morgan from "morgan"; import path, { dirname } from "path"; import { fileURLToPath } from "url"; +import passport from "./config/passport.js"; import errorHandler from "./middlewares/errorHandler.js"; import articlesRouter from "./routes/articleRoutes.js"; import authRouter from "./routes/authRoutes.js"; @@ -27,6 +29,9 @@ app.use(morgan(customFormat, { stream: accessLogStream })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cors()); +app.use(session({ secret: "secret", resave: false, saveUninitialized: true })); +app.use(passport.initialize()); +app.use(passport.session()); app.use("/products", productsRouter); app.use("/articles", articlesRouter); diff --git a/config/passport.js b/config/passport.js new file mode 100644 index 0000000..6a14c18 --- /dev/null +++ b/config/passport.js @@ -0,0 +1,59 @@ +import { PrismaClient } from "@prisma/client"; +import dotenv from "dotenv"; +import passport from "passport"; +import { Strategy as GoogleStrategy } from "passport-google-oauth20"; +import { generateAccessToken, generateRefreshToken } from "../utils/tokens.js"; + +dotenv.config(); +const prisma = new PrismaClient(); + +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; + +passport.use( + new GoogleStrategy( + { + clientID: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + callbackURL: "http://localhost:3000/auth/google/callback", + }, + async (accessToken, refreshToken, profile, done) => { + const { id, displayName, emails } = profile; + + try { + let user = await prisma.user.findUnique({ + where: { googleId: id }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { + googleId: id, + email: emails[0].value, + name: displayName, + nickname: displayName, + password: null, + }, + }); + } + + const accessTokenJwt = generateAccessToken(user); + const refreshTokenJwt = generateRefreshToken(user); + + done(null, { user, accessToken: accessTokenJwt, refreshToken: refreshTokenJwt }); + } catch (error) { + done(error, null); + } + } + ) +); + +passport.serializeUser((userWithTokens, done) => { + done(null, userWithTokens); +}); + +passport.deserializeUser((userWithTokens, done) => { + done(null, userWithTokens); +}); + +export default passport; diff --git a/controllers/authController.js b/controllers/authController.js new file mode 100644 index 0000000..accb542 --- /dev/null +++ b/controllers/authController.js @@ -0,0 +1,74 @@ +import dotenv from "dotenv"; +import jwt from "jsonwebtoken"; +import { assert } from "superstruct"; +import { createUser, findUserByEmail, findUserById, validatePassword } from "../services/authService.js"; +import { CreateUser } from "../structs.js"; +import { generateAccessToken, generateRefreshToken, regenerateRefreshToken } from "../utils/tokens.js"; + +dotenv.config(); +const JWT_SECRET = process.env.JWT_SECRET; + +export const signUp = async (req, res) => { + const { email, password, name, nickname } = req.body; + assert(req.body, CreateUser); + + const existingUser = await findUserByEmail(email); + + if (existingUser) { + return res.status(400).json({ message: "이미 가입된 이메일입니다." }); + } + + await createUser(email, password, name, nickname); + + res.status(201).json({ message: "회원가입이 완료되었습니다." }); +}; + +export const signIn = async (req, res) => { + const { email, password } = req.body; + + const user = await findUserByEmail(email); + + if (!user) { + return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); + } + + const isPasswordValid = await validatePassword(password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); + } + + const accessToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + + res.json({ accessToken, refreshToken }); +}; + +export const refreshToken = async (req, res) => { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(401).json({ message: "토큰은 필수값입니다." }); + } + + try { + const newRefreshToken = regenerateRefreshToken(refreshToken); + const decoded = jwt.verify(newRefreshToken, JWT_SECRET); + const user = await findUserById(decoded.userId); + + if (!user) { + return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); + } + + const accessToken = generateAccessToken(user); + + res.json({ accessToken, refreshToken: newRefreshToken }); + } catch (error) { + return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); + } +}; + +export const googleCallback = (req, res) => { + const { accessToken, refreshToken } = req.user; + res.json({ accessToken, refreshToken }); +}; diff --git a/middlewares/errorHandler.js b/middlewares/errorHandler.js index e042938..c01422f 100644 --- a/middlewares/errorHandler.js +++ b/middlewares/errorHandler.js @@ -1,8 +1,8 @@ import AppError from "../utils/errors.js"; -const errorHandler = (err, req, res) => { - console.error(err.stack); - +const errorHandler = (err, req, res, next) => { + console.error("Error handler triggered:", err.stack); + console.log("Response object:", res); if (err instanceof AppError) { res.status(err.statusCode).json({ status: err.status, diff --git a/package-lock.json b/package-lock.json index 880fe62..49a7618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,15 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-session": "^1.18.0", "is-email": "^1.0.2", "is-uuid": "^1.0.2", "jsonwebtoken": "^9.0.2", "moment-timezone": "^0.5.45", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^5.4.2", "superstruct": "^1.0.3" }, @@ -194,6 +197,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "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", @@ -629,6 +640,42 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1404,6 +1451,11 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1455,6 +1507,61 @@ "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==", + "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==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.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==", + "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", @@ -1468,6 +1575,11 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1532,6 +1644,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1896,6 +2016,22 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index 7334f69..316134b 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,15 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-session": "^1.18.0", "is-email": "^1.0.2", "is-uuid": "^1.0.2", "jsonwebtoken": "^9.0.2", "moment-timezone": "^0.5.45", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^5.4.2", "superstruct": "^1.0.3" }, diff --git a/prisma/migrations/20240528063148_add_google_auth/migration.sql b/prisma/migrations/20240528063148_add_google_auth/migration.sql new file mode 100644 index 0000000..88eb841 --- /dev/null +++ b/prisma/migrations/20240528063148_add_google_auth/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[googleId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "googleId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_googleId_key" ON "User"("googleId"); diff --git a/prisma/migrations/20240528063710_make_password_optional/migration.sql b/prisma/migrations/20240528063710_make_password_optional/migration.sql new file mode 100644 index 0000000..0b600a7 --- /dev/null +++ b/prisma/migrations/20240528063710_make_password_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c3d127..3915113 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,11 +14,12 @@ datasource db { model User { id Int @id @default(autoincrement()) + googleId String? @unique email String @unique name String? nickname String image String? - password String + password String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt products Product[] diff --git a/routes/authRoutes.js b/routes/authRoutes.js index 6152f5f..78ed954 100644 --- a/routes/authRoutes.js +++ b/routes/authRoutes.js @@ -1,97 +1,20 @@ -import { PrismaClient } from "@prisma/client"; -import bcrypt from "bcrypt"; -import dotenv from "dotenv"; import express from "express"; -import jwt from "jsonwebtoken"; -import { assert } from "superstruct"; -import { CreateUser } from "../structs.js"; +import passport from "../config/passport.js"; +import { googleCallback, refreshToken, signIn, signUp } from "../controllers/authController.js"; import asyncHandler from "../utils/asyncHandler.js"; -import { generateAccessToken, generateRefreshToken, regenerateRefreshToken } from "../utils/tokens.js"; -dotenv.config(); const router = express.Router(); -const prisma = new PrismaClient(); -const JWT_SECRET = process.env.JWT_SECRET; +router.post("/signUp", asyncHandler(signUp)); +router.post("/signIn", asyncHandler(signIn)); +router.post("/refresh-token", asyncHandler(refreshToken)); -// 회원가입 -router.post( - "/signUp", - asyncHandler(async (req, res) => { - const { email, password, name, nickname } = req.body; - assert(req.body, CreateUser); - const hashedPassword = await bcrypt.hash(password, 10); - const existingUser = await prisma.user.findUnique({ - where: { email }, - }); +router.get("/google", passport.authenticate("google", { scope: ["profile", "email"] })); +router.get("/google/callback", passport.authenticate("google", { failureRedirect: "/" }), googleCallback); - if (existingUser) { - return res.status(400).json({ message: "이미 가입된 이메일입니다." }); - } - - await prisma.user.create({ - data: { - email, - password: hashedPassword, - name, - nickname, - }, - }); - - res.status(201).json({ message: "회원가입이 완료되었습니다." }); - }) -); -// 사용자 로그인 -router.post( - "/signIn", - asyncHandler(async (req, res) => { - const { email, password } = req.body; - - const user = await prisma.user.findUnique({ - where: { email }, - }); - - if (!user) { - return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); - } - - const isPasswordValid = await bcrypt.compare(password, user.password); - - if (!isPasswordValid) { - return res.status(401).json({ message: "이메일과 비밀번호를 확인해주세요." }); - } - - const accessToken = generateAccessToken(user); - const refreshToken = generateRefreshToken(user); - - res.json({ accessToken, refreshToken }); - }) -); -router.post( - "/refresh-token", - asyncHandler(async (req, res) => { - const { refreshToken } = req.body; - - if (!refreshToken) { - return res.status(401).json({ message: "토큰은 필수값입니다." }); - } - - try { - const newRefreshToken = regenerateRefreshToken(refreshToken); - const decoded = jwt.verify(newRefreshToken, JWT_SECRET); - const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); - - if (!user) { - return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); - } - - const accessToken = generateAccessToken(user); - - res.json({ accessToken, refreshToken: newRefreshToken }); - } catch (error) { - return res.status(401).json({ message: "유효하지 않은 토큰입니다." }); - } - }) -); +router.get("/logout", (req, res) => { + req.logout(); + res.redirect("/"); +}); export default router; diff --git a/services/authService.js b/services/authService.js new file mode 100644 index 0000000..f736007 --- /dev/null +++ b/services/authService.js @@ -0,0 +1,32 @@ +import { PrismaClient } from "@prisma/client"; +import bcrypt from "bcrypt"; + +const prisma = new PrismaClient(); + +export const createUser = async (email, password, name, nickname) => { + const hashedPassword = await bcrypt.hash(password, 10); + return prisma.user.create({ + data: { + email, + password: hashedPassword, + name, + nickname, + }, + }); +}; + +export const findUserByEmail = async (email) => { + return prisma.user.findUnique({ + where: { email }, + }); +}; + +export const findUserById = async (id) => { + return prisma.user.findUnique({ + where: { id }, + }); +}; + +export const validatePassword = async (inputPassword, storedPassword) => { + return bcrypt.compare(inputPassword, storedPassword); +}; From acec3d653c7850f20ca8d886acadda705993219a Mon Sep 17 00:00:00 2001 From: aowjarkwk Date: Wed, 29 May 2024 07:54:18 +0900 Subject: [PATCH 36/36] =?UTF-8?q?[#M10]=20feat:=20Swagger=20API=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 12 +- http/articles.http | 2 +- http/auth.http | 2 +- middlewares/authenticate.js | 2 +- package-lock.json | 236 +++++++++- package.json | 4 +- routes/articleRoutes.js | 900 +++++++++++++++++++++++++++++++++++- routes/authRoutes.js | 203 ++++++++ routes/imageRoutes.js | 48 +- routes/productRoutes.js | 829 +++++++++++++++++++++++++++++++++ services/articleService.js | 2 +- swagger/swaggerOptions.js | 42 ++ 12 files changed, 2273 insertions(+), 9 deletions(-) create mode 100644 swagger/swaggerOptions.js diff --git a/app.js b/app.js index 47c0734..44513bb 100644 --- a/app.js +++ b/app.js @@ -1,10 +1,13 @@ import cors from "cors"; +import dotenv from "dotenv"; import express from "express"; import session from "express-session"; import fs from "fs"; import moment from "moment-timezone"; import morgan from "morgan"; import path, { dirname } from "path"; +import swaggerJsdoc from "swagger-jsdoc"; +import swaggerUi from "swagger-ui-express"; import { fileURLToPath } from "url"; import passport from "./config/passport.js"; import errorHandler from "./middlewares/errorHandler.js"; @@ -12,11 +15,18 @@ import articlesRouter from "./routes/articleRoutes.js"; import authRouter from "./routes/authRoutes.js"; import imagesRouter from "./routes/imageRoutes.js"; import productsRouter from "./routes/productRoutes.js"; +import swaggerOptions from "./swagger/swaggerOptions.js"; +dotenv.config(); +const { JWT_SECRET } = process.env; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); +const specs = swaggerJsdoc(swaggerOptions); + +app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs)); + morgan.token("date", (req, res, tz) => { return moment().tz(tz).format("YYYY-MM-DD HH:mm:ss"); }); @@ -29,7 +39,7 @@ app.use(morgan(customFormat, { stream: accessLogStream })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cors()); -app.use(session({ secret: "secret", resave: false, saveUninitialized: true })); +app.use(session({ secret: JWT_SECRET, resave: false, saveUninitialized: true })); app.use(passport.initialize()); app.use(passport.session()); diff --git a/http/articles.http b/http/articles.http index 99e2a4a..510a021 100644 --- a/http/articles.http +++ b/http/articles.http @@ -9,7 +9,7 @@ GET http://localhost:3000/articles/2c027764-d7ef-4a94-8399-f15ffbf8f4da ### # 게시글 등록 POST http://localhost:3000/articles -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg3NDc2NywiZXhwIjoxNzE2ODc1NjY3fQ.chuRJN6EHR4x2r9-ocN1lKQKwYrSnZKYULaSgj5oU88 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjIsImlhdCI6MTcxNjg4MjIxMCwiZXhwIjoxNzE2ODgzMTEwfQ.HKPt_52tCohwFBk5yESkDiChtWpqd7pf531uQsI47kY Content-Type: application/json { diff --git a/http/auth.http b/http/auth.http index 4868f2e..aeaf407 100644 --- a/http/auth.http +++ b/http/auth.http @@ -2,7 +2,7 @@ POST http://localhost:3000/auth/signUp Content-Type: application/json { - "email":"test5@gmail.com", + "email":"test52@gmail.com", "password":"pandapower", "name":"김판다", "nickname":"판다의 왕" diff --git a/middlewares/authenticate.js b/middlewares/authenticate.js index f891647..cb54af3 100644 --- a/middlewares/authenticate.js +++ b/middlewares/authenticate.js @@ -8,7 +8,7 @@ const JWT_SECRET = process.env.JWT_SECRET; const authenticate = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) { - return res.status(401).json({ message: "No token provided" }); + return res.status(401).json({ message: "인증 토큰이 제공되지 않았습니다." }); } const token = authHeader.split(" ")[1]; diff --git a/package-lock.json b/package-lock.json index 49a7618..4efa6b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,59 @@ "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "prisma": "^5.4.2", - "superstruct": "^1.0.3" + "superstruct": "^1.0.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0" }, "devDependencies": { "nodemon": "^3.0.1" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -102,6 +149,11 @@ "@prisma/debug": "5.14.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -187,6 +239,11 @@ "node": ">= 6" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -350,6 +407,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -390,6 +452,14 @@ "color-support": "bin.js" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -530,6 +600,17 @@ "node": ">=8" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -591,6 +672,14 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1067,6 +1156,17 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -1107,6 +1207,11 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1117,6 +1222,11 @@ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -1137,6 +1247,11 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -1499,6 +1614,12 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "peer": true + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1938,6 +2059,75 @@ "node": ">=4" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.0.tgz", + "integrity": "sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -2059,6 +2249,14 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2106,6 +2304,42 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/package.json b/package.json index 316134b..fad07ce 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "prisma": "^5.4.2", - "superstruct": "^1.0.3" + "superstruct": "^1.0.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/routes/articleRoutes.js b/routes/articleRoutes.js index bfba509..c2d9e4d 100644 --- a/routes/articleRoutes.js +++ b/routes/articleRoutes.js @@ -3,23 +3,921 @@ import * as articleController from "../controllers/articleController.js"; import * as commentController from "../controllers/commentController.js"; import authenticate from "../middlewares/authenticate.js"; const router = express.Router(); -router.route("/").get(articleController.getArticles).post(authenticate, articleController.createArticle); +/** + * @swagger + * tags: + * name: Articles + * description: 자유게시판 + */ + +/** + * @swagger + * /articles: + * get: + * summary: 게시글 목록 조회 + * tags: [Articles] + * parameters: + * - in: query + * name: offset + * schema: + * type: integer + * example: 0 + * description: 가져올 데이터의 시작 지점 + * - in: query + * name: limit + * schema: + * type: integer + * example: 10 + * description: 한 번에 가져올 데이터의 개수 + * - in: query + * name: orderBy + * schema: + * type: string + * enum: [like, recent] + * example: recent + * description: 정렬 기준 + * - in: query + * name: keyword + * schema: + * type: string + * example: "게시글 제목" + * description: 검색 키워드 + * responses: + * 200: + * description: 게시글 목록 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * articles: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 5 + * writer: + * type: string + * example: "작성자" + * bestArticles: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "인기 게시글 제목" + * content: + * type: string + * example: "인기 게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 10 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * articles: + * - id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 5 + * writer: "작성자" + * bestArticles: + * - id: "550e8400-e29b-41d4-a716-446655440000" + * title: "인기 게시글 제목" + * content: "인기 게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 10 + * writer: "작성자" + * post: + * summary: 게시글 생성 + * tags: [Articles] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * responses: + * 201: + * description: 게시글 생성 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 0 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 0 + * writer: "작성자" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * + */ +router.route("/").get(articleController.getArticles).post(authenticate, articleController.createArticle); +/** + * @swagger + * /articles/{id}: + * get: + * summary: 특정 게시글 조회 + * tags: [Articles] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 게시글 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 5 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 5 + * writer: "작성자" + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * patch: + * summary: 게시글 수정 + * tags: [Articles] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * responses: + * 200: + * description: 게시글 수정 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 5 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 5 + * writer: "작성자" + * 400: + * description: 유효성 검사 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "유효성 검사 오류입니다." + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "게시글을 수정할 권한이 없습니다." + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * delete: + * summary: 게시글 삭제 + * tags: [Articles] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 204: + * description: 게시글 삭제 성공 + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "게시글을 삭제할 권한이 없습니다." + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + */ router .route("/:id") .get(articleController.getArticleById) .patch(authenticate, articleController.updateArticle) .delete(authenticate, articleController.deleteArticle); +/** + * @swagger + * /articles/{id}/like: + * patch: + * summary: 게시글 좋아요 + * tags: [Articles] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 게시글 좋아요 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 6 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 6 + * writer: "작성자" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 409: + * description: 이미 좋아요 처리된 게시글 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이미 좋아요 처리된 게시글입니다." + */ router.route("/:id/like").patch(authenticate, articleController.likeArticle); +/** + * @swagger + * /articles/{id}/unlike: + * patch: + * summary: 게시글 좋아요 취소 + * tags: [Articles] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 게시글 좋아요 취소 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * title: + * type: string + * example: "게시글 제목" + * content: + * type: string + * example: "게시글 내용" + * imageUrl: + * type: string + * example: "http://example.com/image.jpg" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * likeCount: + * type: number + * example: 4 + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * title: "게시글 제목" + * content: "게시글 내용" + * imageUrl: "http://example.com/image.jpg" + * createdAt: "2023-01-01T00:00:00.000Z" + * likeCount: 4 + * writer: "작성자" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 409: + * description: 아직 좋아요 처리되지 않은 게시글 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "아직 좋아요 처리되지 않은 게시글입니다." + */ router.route("/:id/unlike").patch(authenticate, articleController.unlikeArticle); +/** + * @swagger + * /articles/{articleId}/comments: + * get: + * summary: 특정 게시글의 댓글 목록 조회 + * tags: [Comments] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 댓글 목록 조회 성공 + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * - id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * post: + * summary: 댓글 작성 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * content: + * type: string + * example: "댓글 내용" + * responses: + * 201: + * description: 댓글 작성 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 400: + * description: 유효성 검사 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "유효성 검사 오류입니다." + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + */ + +/** + * @swagger + * tags: + * name: Comments + * description: 댓글 + */ + router .route("/:articleId/comments") .get(commentController.getCommentsByProductId) .post(authenticate, commentController.createComment); +/** + * @swagger + * /articles/{articleId}/comments/{commentId}: + * patch: + * summary: 댓글 수정 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * - in: path + * name: commentId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * content: + * type: string + * example: "댓글 내용" + * responses: + * 200: + * description: 댓글 수정 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이 댓글을 수정할 권한이 없습니다." + * 404: + * description: 댓글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 댓글입니다." + * delete: + * summary: 댓글 삭제 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * - in: path + * name: commentId + * required: true + * schema: + * type: string + * responses: + * 204: + * description: 댓글 삭제 성공 + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이 댓글을 삭제할 권한이 없습니다." + * 404: + * description: 댓글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 댓글입니다." + */ router .route("/:articleId/comments/:commentId") .patch(authenticate, commentController.updateComment) diff --git a/routes/authRoutes.js b/routes/authRoutes.js index 78ed954..0a598a6 100644 --- a/routes/authRoutes.js +++ b/routes/authRoutes.js @@ -5,13 +5,216 @@ import asyncHandler from "../utils/asyncHandler.js"; const router = express.Router(); +/** + * @swagger + * tags: + * name: Auth + * description: 사용자 인증 + */ + +/** + * @swagger + * /auth/signUp: + * post: + * summary: 사용자 회원가입 + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * - name + * - nickname + * properties: + * email: + * type: string + * example: "pandaking@example.com" + * password: + * type: string + * example: "password123" + * name: + * type: string + * example: "김판다" + * nickname: + * type: string + * example: "판다의 왕" + * responses: + * 201: + * description: 회원가입이 완료되었습니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "회원가입이 완료되었습니다." + * 400: + * description: 이미 가입된 이메일입니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이미 가입된 이메일입니다." + */ router.post("/signUp", asyncHandler(signUp)); + +/** + * @swagger + * /auth/signIn: + * post: + * summary: 사용자 로그인 + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * example: "user@example.com" + * password: + * type: string + * example: "password123" + * responses: + * 200: + * description: 로그인 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * accessToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * refreshToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * 401: + * description: 이메일과 비밀번호를 확인해주세요. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이메일과 비밀번호를 확인해주세요." + */ router.post("/signIn", asyncHandler(signIn)); + +/** + * @swagger + * /auth/refresh-token: + * post: + * summary: JWT 토큰 갱신 + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - refreshToken + * properties: + * refreshToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * responses: + * 200: + * description: 토큰 갱신 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * accessToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * refreshToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * 401: + * description: 유효하지 않은 토큰입니다. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "유효하지 않은 토큰입니다." + */ router.post("/refresh-token", asyncHandler(refreshToken)); +/** + * @swagger + * /auth/google: + * get: + * summary: 구글 인증 + * tags: [Auth] + * responses: + * 302: + * description: 구글 인증 페이지로 리디렉션 + */ router.get("/google", passport.authenticate("google", { scope: ["profile", "email"] })); + +/** + * @swagger + * /auth/google/callback: + * get: + * summary: 구글 OAuth 콜백 + * tags: [Auth] + * responses: + * 200: + * description: 구글 인증 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * accessToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * refreshToken: + * type: string + * example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * 401: + * description: 구글 인증 실패 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "구글 인증 실패" + */ router.get("/google/callback", passport.authenticate("google", { failureRedirect: "/" }), googleCallback); +/** + * @swagger + * /auth/logout: + * get: + * summary: 로그아웃 + * tags: [Auth] + * responses: + * 302: + * description: 로그아웃 후 리디렉션 + */ router.get("/logout", (req, res) => { req.logout(); res.redirect("/"); diff --git a/routes/imageRoutes.js b/routes/imageRoutes.js index f413c71..4966f1a 100644 --- a/routes/imageRoutes.js +++ b/routes/imageRoutes.js @@ -18,7 +18,53 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage }); -// 이미지 업로드 +/** + * @swagger + * tags: + * name: Images + * description: 이미지 관리 + */ + +/** + * @swagger + * /images/upload: + * post: + * summary: 이미지 업로드 + * tags: [Images] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: string + * format: binary + * responses: + * 200: + * description: 이미지 업로드 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * example: "http://localhost:3000/uploads/image-123456789.jpg" + * 400: + * description: 이미지 파일이 선택되지 않음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이미지 파일을 선택해주세요." + */ router.post("/upload", authenticate, upload.single("image"), imageController.uploadImage); export default router; diff --git a/routes/productRoutes.js b/routes/productRoutes.js index 49386ad..0ea88cd 100644 --- a/routes/productRoutes.js +++ b/routes/productRoutes.js @@ -4,22 +4,851 @@ import * as productController from "../controllers/productController.js"; import authenticate from "../middlewares/authenticate.js"; const router = express.Router(); +/** + * @swagger + * tags: + * name: Products + * description: 중고마켓 + */ + +/** + * @swagger + * /products: + * get: + * summary: 상품 목록 조회 + * tags: [Products] + * parameters: + * - in: query + * name: offset + * schema: + * type: integer + * example: 0 + * description: 가져올 데이터의 시작 지점 + * - in: query + * name: limit + * schema: + * type: integer + * example: 10 + * description: 한 번에 가져올 데이터의 개수 + * - in: query + * name: orderBy + * schema: + * type: string + * enum: [favorite, recent] + * example: recent + * description: 정렬 기준 + * - in: query + * name: keyword + * schema: + * type: string + * example: "상품 이름" + * description: 검색 키워드 + * responses: + * 200: + * description: 상품 목록 조회 성공 + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * - id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 5 + * createdAt: "2023-01-01T00:00:00.000Z" + * post: + * summary: 상품 생성 + * tags: [Products] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * responses: + * 201: + * description: 상품 생성 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 5 + * createdAt: "2023-01-01T00:00:00.000Z" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + */ router.route("/").get(productController.getProducts).post(authenticate, productController.createProduct); +/** + * @swagger + * /products/{id}: + * get: + * summary: 특정 상품 조회 + * tags: [Products] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 상품 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 5 + * createdAt: "2023-01-01T00:00:00.000Z" + * 404: + * description: 상품을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * patch: + * summary: 상품 수정 + * tags: [Products] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * responses: + * 200: + * description: 상품 수정 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 5 + * createdAt: "2023-01-01T00:00:00.000Z" + * 400: + * description: 유효성 검사 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "유효성 검사 오류입니다." + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "상품을 수정할 권한이 없습니다." + * 404: + * description: 상품을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * delete: + * summary: 상품 삭제 + * tags: [Products] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 204: + * description: 상품 삭제 성공 + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "상품을 삭제할 권한이 없습니다." + * 404: + * description: 상품을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + */ router .route("/:id") .get(productController.getProductById) .patch(authenticate, productController.updateProduct) .delete(authenticate, productController.deleteProduct); +/** + * @swagger + * /products/{id}/like: + * patch: + * summary: 상품 좋아요 + * tags: [Products] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 상품 좋아요 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 6 + * createdAt: "2023-01-01T00:00:00.000Z" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 409: + * description: 이미 좋아요 처리된 상품 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이미 좋아요 처리된 상품입니다." + */ router.route("/:id/like").patch(authenticate, productController.likeProduct); +/** + * @swagger + * /products/{id}/unlike: + * patch: + * summary: 상품 좋아요 취소 + * tags: [Products] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 상품 좋아요 취소 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * name: + * type: string + * example: "상품 이름" + * description: + * type: string + * example: "상품 설명" + * price: + * type: number + * example: 1000 + * favoriteCount: + * type: number + * example: 5 + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * examples: + * application/json: + * value: + * id: "1" + * name: "상품 이름" + * description: "상품 설명" + * price: 1000 + * favoriteCount: 4 + * createdAt: "2023-01-01T00:00:00.000Z" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 409: + * description: 아직 좋아요 처리되지 않은 상품 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "아직 좋아요 처리되지 않은 상품입니다." + */ router.route("/:id/unlike").patch(authenticate, productController.unlikeProduct); + +/** + * @swagger + * /products/{productId}/comments: + * get: + * summary: 특정 상품의 댓글 목록 조회 + * tags: [Comments] + * parameters: + * - in: path + * name: productId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: 댓글 목록 조회 성공 + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * - id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + * post: + * summary: 댓글 작성 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * content: + * type: string + * example: "댓글 내용" + * responses: + * 201: + * description: 댓글 작성 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 400: + * description: 유효성 검사 오류 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "유효성 검사 오류입니다." + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 404: + * description: 게시글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 게시글입니다." + */ + router .route("/:productId/comments") .get(commentController.getCommentsByProductId) .post(authenticate, commentController.createComment); +/** + * @swagger + * /products/{productId}/comments/{commentId}: + * patch: + * summary: 댓글 수정 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: productId + * required: true + * schema: + * type: string + * - in: path + * name: commentId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * content: + * type: string + * example: "댓글 내용" + * responses: + * 200: + * description: 댓글 수정 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "550e8400-e29b-41d4-a716-446655440000" + * content: + * type: string + * example: "댓글 내용" + * createdAt: + * type: string + * format: date-time + * example: "2023-01-01T00:00:00.000Z" + * writer: + * type: string + * example: "작성자" + * examples: + * application/json: + * value: + * id: "550e8400-e29b-41d4-a716-446655440000" + * content: "댓글 내용" + * createdAt: "2023-01-01T00:00:00.000Z" + * writer: "작성자" + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이 댓글을 수정할 권한이 없습니다." + * 404: + * description: 댓글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 댓글입니다." + * delete: + * summary: 댓글 삭제 + * tags: [Comments] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: articleId + * required: true + * schema: + * type: string + * - in: path + * name: commentId + * required: true + * schema: + * type: string + * responses: + * 204: + * description: 댓글 삭제 성공 + * 401: + * description: 인증 필요 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * examples: + * noToken: + * summary: 토큰이 제공되지 않은 경우 + * value: + * message: "인증 토큰이 제공되지 않았습니다." + * invalidToken: + * summary: 유효하지 않은 토큰인 경우 + * value: + * message: "유효하지 않은 토큰입니다." + * 403: + * description: 권한 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "이 댓글을 삭제할 권한이 없습니다." + * 404: + * description: 댓글을 찾을 수 없음 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "존재하지 않는 댓글입니다." + */ + router .route("/:productId/comments/:commentId") .patch(authenticate, commentController.updateComment) diff --git a/services/articleService.js b/services/articleService.js index 3832800..ce3148c 100644 --- a/services/articleService.js +++ b/services/articleService.js @@ -145,7 +145,7 @@ export const unlikeArticle = async (articleId, userId) => { }); if (!favorite) { - throw new AppError("아직 좋아요 처리되지 않은 게시글입니다.", 400); + throw new AppError("아직 좋아요 처리되지 않은 게시글입니다.", 409); } const [deletedFavorite, updatedArticle] = await prisma.$transaction([ diff --git a/swagger/swaggerOptions.js b/swagger/swaggerOptions.js new file mode 100644 index 0000000..454df85 --- /dev/null +++ b/swagger/swaggerOptions.js @@ -0,0 +1,42 @@ +import { readFileSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageJson = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8")); +const { version } = packageJson; + +const swaggerOptions = { + swaggerDefinition: { + openapi: "3.0.0", + info: { + title: "Panda Market API", + version, + description: "API documentation for Panda Market", + }, + servers: [ + { + url: "http://localhost:3000", + description: "Development server", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + apis: ["./routes/*.js"], +}; + +export default swaggerOptions;