diff --git a/.gitignore b/.gitignore index a2c550f..bf61fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ env/* build # misc +.vscode .DS_Store .env.local .env.development.local diff --git a/package.json b/package.json index f53ea4b..dc73a38 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev": "nodemon --env-file=./env/.env.development src/server.js", + "dev": "nodemon src/server.js", "start": "node src/server.js", "seed": "node scripts/seed.js", "format": "prettier --write src/**/*.js", @@ -20,9 +20,15 @@ "npm": "^11.6.0" }, "dependencies": { - "@prisma/client": "^6.16.2", "@faker-js/faker": "^10.0.0", + "@prisma/client": "^6.16.2", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", "express": "^5.1.0", + "fs": "0.0.1-security", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "path": "^0.12.7", "zod": "^4.1.11" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cf54a4..d829453 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,27 @@ importers: '@prisma/client': specifier: ^6.16.2 version: 6.16.2(prisma@6.16.2) + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 express: specifier: ^5.1.0 version: 5.1.0 + fs: + specifier: 0.0.1-security + version: 0.0.1-security + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + multer: + specifier: ^2.0.2 + version: 2.0.2 + path: + specifier: ^0.12.7 + version: 0.12.7 zod: specifier: ^4.1.11 version: 4.1.11 @@ -161,12 +179,19 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -182,6 +207,16 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -231,6 +266,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} @@ -246,6 +285,13 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -292,6 +338,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -422,6 +471,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs@0.0.1-security: + resolution: {integrity: sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -501,6 +553,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -547,6 +602,16 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -558,13 +623,38 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -573,10 +663,18 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} @@ -584,9 +682,20 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -594,9 +703,17 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + nodemon@3.1.10: resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} engines: {node: '>=10'} @@ -611,6 +728,10 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -656,6 +777,9 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path@0.12.7: + resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -688,6 +812,10 @@ packages: typescript: optional: true + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -717,6 +845,10 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -791,6 +923,13 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -822,10 +961,17 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} @@ -836,6 +982,12 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.10.4: + resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -852,6 +1004,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -985,10 +1141,17 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + append-field@1.0.0: {} + argparse@2.0.1: {} balanced-match@1.0.2: {} + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + binary-extensions@2.3.0: {} body-parser@2.2.0: @@ -1014,6 +1177,14 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} c12@3.1.0: @@ -1076,6 +1247,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.2.2: {} consola@3.4.2: {} @@ -1086,6 +1264,13 @@ snapshots: content-type@1.0.5: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -1120,6 +1305,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.16.12: @@ -1293,6 +1482,8 @@ snapshots: fresh@2.0.0: {} + fs@0.0.1-security: {} + fsevents@2.3.3: optional: true @@ -1374,6 +1565,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.3: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -1406,6 +1599,30 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonwebtoken@9.0.2: + 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.1.1 + ms: 2.1.3 + semver: 7.7.2 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -1419,16 +1636,38 @@ snapshots: dependencies: p-locate: 5.0.0 + 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.merge@4.6.2: {} + lodash.once@4.1.1: {} + math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 @@ -1437,14 +1676,34 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + ms@2.1.3: {} + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + natural-compare@1.4.0: {} negotiator@1.0.0: {} + node-addon-api@8.5.0: {} + node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + nodemon@3.1.10: dependencies: chokidar: 3.6.0 @@ -1468,6 +1727,8 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.1 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} ohash@2.0.11: {} @@ -1509,6 +1770,11 @@ snapshots: path-to-regexp@8.3.0: {} + path@0.12.7: + dependencies: + process: 0.11.10 + util: 0.10.4 + pathe@2.0.3: {} perfect-debounce@1.0.0: {} @@ -1532,6 +1798,8 @@ snapshots: transitivePeerDependencies: - magicast + process@0.11.10: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -1561,6 +1829,12 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -1654,6 +1928,12 @@ snapshots: statuses@2.0.2: {} + streamsearch@1.1.0: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-json-comments@3.1.1: {} supports-color@5.5.0: @@ -1678,12 +1958,19 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.1 + typedarray@0.0.6: {} + undefsafe@2.0.5: {} unpipe@1.0.0: {} @@ -1692,6 +1979,12 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + + util@0.10.4: + dependencies: + inherits: 2.0.3 + vary@1.1.2: {} which@2.0.2: @@ -1702,6 +1995,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + yocto-queue@0.1.0: {} zod@4.1.11: {} diff --git a/prisma/migrations/20251120061057_add_user/migration.sql b/prisma/migrations/20251120061057_add_user/migration.sql new file mode 100644 index 0000000..44b2f24 --- /dev/null +++ b/prisma/migrations/20251120061057_add_user/migration.sql @@ -0,0 +1,80 @@ +/* + Warnings: + + - 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 `Decimal(65,30)` to `Integer`. + - Added the required column `authorId` to the `Article` table without a default value. This is not possible if the table is not empty. + - Added the required column `authorId` to the `Comment` table without a default value. This is not possible if the table is not empty. + - Added the required column `authorId` to the `Product` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."Article" ADD COLUMN "authorId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "public"."Comment" ADD COLUMN "authorId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "public"."Product" ADD COLUMN "authorId" TEXT NOT NULL, +ALTER COLUMN "price" SET DATA TYPE INTEGER; + +-- CreateTable +CREATE TABLE "public"."User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "nickname" 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 "public"."LikeArticle" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "articleId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LikeArticle_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."LikeProduct" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LikeProduct_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "LikeArticle_userId_articleId_key" ON "public"."LikeArticle"("userId", "articleId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LikeProduct_userId_productId_key" ON "public"."LikeProduct"("userId", "productId"); + +-- AddForeignKey +ALTER TABLE "public"."Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."LikeArticle" ADD CONSTRAINT "LikeArticle_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."LikeArticle" ADD CONSTRAINT "LikeArticle_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "public"."Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Product" ADD CONSTRAINT "Product_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."LikeProduct" ADD CONSTRAINT "LikeProduct_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."LikeProduct" ADD CONSTRAINT "LikeProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "public"."Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251124044324_add_refresh_token_for_user/migration.sql b/prisma/migrations/20251124044324_add_refresh_token_for_user/migration.sql new file mode 100644 index 0000000..5d337e2 --- /dev/null +++ b/prisma/migrations/20251124044324_add_refresh_token_for_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."User" ADD COLUMN "refreshToken" TEXT; diff --git a/prisma/migrations/20251125002246_add_product_image/migration.sql b/prisma/migrations/20251125002246_add_product_image/migration.sql new file mode 100644 index 0000000..3ee9a11 --- /dev/null +++ b/prisma/migrations/20251125002246_add_product_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Product" ADD COLUMN "image" TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cba7d6b..ac3edef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,6 +6,7 @@ generator client { provider = "prisma-client-js" + } datasource db { @@ -13,24 +14,66 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(uuid()) + email String @unique + nickname String? + password String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + refreshToken String? + articles Article[] + products Product[] + comments Comment[] + likedArticles LikeArticle[] + likedProducts LikeProduct[] +} + model Article { id String @id @default(cuid()) title String content String @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) comments Comment[] + likes LikeArticle[] +} + +model LikeArticle { + id Int @id @default(autoincrement()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + articleId String + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + @@unique([userId, articleId]) } model Product { id String @id @default(cuid()) name String description String @db.Text - price Decimal - tags String[] + price Int + tags String[] + image String[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) comments Comment[] + likes LikeProduct[] +} + +model LikeProduct { + id Int @id @default(autoincrement()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + @@unique([userId, productId]) } enum ParentType { @@ -43,6 +86,8 @@ model Comment { content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) parent ParentType // Article의 댓글인 경우 articleId String? diff --git a/scripts/seed.js b/scripts/seed.js deleted file mode 100644 index ffab3ef..0000000 --- a/scripts/seed.js +++ /dev/null @@ -1,92 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { faker } from '@faker-js/faker'; - -const prisma = new PrismaClient(); - -async function main() { - console.log( - '시딩을 시작합니다... (모든 작업은 단일 트랜잭션으로 실행됩니다)', - ); - - await prisma.$transaction(async (tx) => { - // 1. 기존 데이터 삭제 - console.log('기존 데이터를 삭제합니다...'); - await tx.comment.deleteMany(); - await tx.article.deleteMany(); - await tx.product.deleteMany(); - console.log('기존 데이터 삭제 완료.'); - - const DATA_LENGTH = 15; - const paragraphCount = Math.max(1, Math.floor(Math.random() * 5)); - - // 2. Article 15개 생성 - console.log('Article 데이터를 생성합니다...'); - const articleCreatePromises = Array.from({ length: DATA_LENGTH }).map(() => - tx.article.create({ - data: { - title: faker.lorem.sentence({ min: 5, max: 10 }), - content: faker.lorem.paragraphs(paragraphCount), - }, - }), - ); - const articles = await Promise.all(articleCreatePromises); - console.log(`${articles.length}개의 Article이 생성되었습니다.`); - - // 3. Product 15개 생성 - console.log('Product 데이터를 생성합니다...'); - const productCreatePromises = Array.from({ length: DATA_LENGTH }).map(() => - tx.product.create({ - data: { - name: faker.commerce.productName({ max: 10 }), - description: faker.commerce.productDescription({ min: 10, max: 100 }), - price: faker.commerce.price({ min: 10000, max: 200000 }), - tags: Array.from({ length: Math.floor(Math.random() * 3) + 1 }, () => - faker.commerce.department(), - ), - }, - }), - ); - const products = await Promise.all(productCreatePromises); - console.log(`${products.length}개의 Product가 생성되었습니다.`); - - // 4. Article에 대한 Comment 생성 - console.log('Article에 대한 Comment를 생성합니다...'); - const articleCommentsData = articles.flatMap((article) => - Array.from({ length: 3 }).map(() => ({ - content: faker.lorem.sentence({ min: 10, max: 25 }), - parent: 'Article', - articleId: article.id, - })), - ); - const { count: articleCommentCount } = await tx.comment.createMany({ - data: articleCommentsData, - }); - console.log(`${articleCommentCount}개의 Article Comment가 생성되었습니다.`); - - // 5. Product에 대한 Comment 생성 - console.log('Product에 대한 Comment를 생성합니다...'); - const productCommentsData = products.flatMap((product) => - Array.from({ length: 3 }).map(() => ({ - content: faker.lorem.sentence({ min: 10, max: 25 }), - parent: 'Product', - productId: product.id, - })), - ); - const { count: productCommentCount } = await tx.comment.createMany({ - data: productCommentsData, - }); - console.log(`${productCommentCount}개의 Product Comment가 생성되었습니다.`); - }); - - console.log('시딩이 완료되었습니다.'); -} - -main() - .catch((err) => { - console.error('시딩 중 오류가 발생했습니다:', err); - process.exit(1); - }) - .finally(async () => { - // 스크립트 종료 시 Prisma Client 연결 해제 - await prisma.$disconnect(); - }); diff --git a/src/config/config.js b/src/config/config.js index 1825520..b666eca 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -7,6 +7,7 @@ const envSchema = z.object({ PORT: z.coerce.number().min(1000).max(65535), DATABASE_URL: z.string().startsWith('postgresql://'), FRONT_URL: z.string(), + JWT_SECRET: z.string(), }); const parseEnvironment = () => { @@ -16,6 +17,7 @@ const parseEnvironment = () => { PORT: process.env.PORT, DATABASE_URL: process.env.DATABASE_URL, FRONT_URL: process.env.FRONT_URL, + JWT_SECRET: process.env.JWT_SECRET, }); } catch (err) { if (err instanceof z.ZodError) { diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js new file mode 100644 index 0000000..dccd018 --- /dev/null +++ b/src/middlewares/auth.middleware.js @@ -0,0 +1,72 @@ +import jwt from 'jsonwebtoken'; +import usersRepository from '../repository/users.repository.js'; +import { UnauthorizedException } from '../err/unauthorizedException.js'; +import { config } from '../config/config.js'; + +const verifyToken = (token) => { + try { + return jwt.verify(token, config.JWT_SECRET); + } catch (err) { + return null; + } +}; + +export const authMiddleware = async (req, res, next) => { + try { + const { authorization } = req.cookies; + if (!authorization) { + throw new UnauthorizedException('인증 정보가 없습니다.'); + } + const [tokenType, token] = authorization.split(' '); + if (tokenType !== 'Bearer' || !token) { + throw new UnauthorizedException('지원하지 않는 인증 방식입니다.'); + } + + let decoded = verifyToken(token); + + // Access Token이 만료된 경우 + if (!decoded) { + const { refreshToken: refreshTokenWithBearer } = req.cookies; + if (!refreshTokenWithBearer) { + throw new UnauthorizedException('인증 정보가 만료되었습니다.'); + } + + const [refreshTokenType, refreshToken] = + refreshTokenWithBearer.split(' '); + if (refreshTokenType !== 'Bearer' || !refreshToken) { + throw new UnauthorizedException('지원하지 않는 인증 방식입니다.'); + } + + const decodedRefreshToken = verifyToken(refreshToken); + if (!decodedRefreshToken) { + throw new UnauthorizedException('인증 정보가 만료되었습니다.'); + } + + const user = await usersRepository.findUserById( + decodedRefreshToken.userId, + ); + if (!user || user.refreshToken !== refreshToken) { + throw new UnauthorizedException('인증 정보가 유효하지 않습니다.'); + } + + // 새로운 Access Token 발급 + const newAccessToken = jwt.sign({ userId: user.id }, config.JWT_SECRET, { + expiresIn: '6h', + }); + + res.cookie('authorization', `Bearer ${newAccessToken}`); + decoded = verifyToken(newAccessToken); + } + + const user = await usersRepository.findUserById(decoded.userId); + if (!user) { + throw new UnauthorizedException( + '인증 정보와 일치하는 사용자가 없습니다.', + ); + } + req.user = user; + next(); + } catch (err) { + next(err); + } +}; diff --git a/src/middlewares/pagination.middleware.js b/src/middlewares/pagination.middleware.js new file mode 100644 index 0000000..e3845aa --- /dev/null +++ b/src/middlewares/pagination.middleware.js @@ -0,0 +1,10 @@ +export const parsePagination = (req, res, next) => { + const pageSize = parseInt(req.query.pageSize, 10) || 10; + const cursor = req.query.cursor; + + req.pagination = { + take: pageSize, + cursor, + }; + next(); +}; diff --git a/src/repository/article.repository.js b/src/repository/articles.repository.js similarity index 95% rename from src/repository/article.repository.js rename to src/repository/articles.repository.js index bcdfd1e..35f1e6a 100644 --- a/src/repository/article.repository.js +++ b/src/repository/articles.repository.js @@ -23,7 +23,7 @@ async function deleteArticle(id) { return await prisma.article.delete({ where: { id: String(id) } }); } -export const articleRepository = { +export const articlesRepository = { createArticle, findArticleById, findArticlesMany, diff --git a/src/repository/comment.repository.js b/src/repository/comment.repository.js deleted file mode 100644 index 27c925b..0000000 --- a/src/repository/comment.repository.js +++ /dev/null @@ -1,72 +0,0 @@ -import { prisma } from '../db/prisma.js'; - -async function createComment(data) { - return await prisma.comment.create({ data }); -} - -async function findCommentsInArticle() { - return await prisma.$transaction([ - prisma.comment.count({ where: { parent: String('Article') } }), - prisma.comment.findMany( - { where: { parent: String('Article') } }, - { orderBy: { createdAt: 'desc' } }, - ), - ]); -} - -async function findCommentsByArticleId(articleId) { - return await prisma.$transaction([ - prisma.comment.count({ where: { articleId: String(articleId) } }), - prisma.comment.findMany( - { where: { parent: String('Article') } }, - { orderBy: { createdAt: 'desc' } }, - ), - ]); -} - -async function findCommentsInProduct() { - return await prisma.$transaction([ - prisma.comment.count({ where: { parent: String('Product') } }), - prisma.comment.findMany( - { where: { parent: String('Product') } }, - { orderBy: { createdAt: 'desc' } }, - ), - ]); -} - -async function findCommentsByProductId(productId) { - return await prisma.$transaction([ - prisma.comment.count({ where: { productId: String(productId) } }), - prisma.comment.findMany( - { where: { parent: String('Product') } }, - { orderBy: { createdAt: 'desc' } }, - ), - ]); -} - -async function findCommentById(id) { - return await prisma.comment.findUnique({ where: { id: String(id) } }); -} - -async function updateComment(id, data) { - return await prisma.comment.update({ where: { id: String(id) }, data }); -} - -async function deleteComment(id) { - return await prisma.comment.delete({ where: { id: String(id) } }); -} - -export const commentRepository = { - findCommentsInArticle, - findCommentsInProduct, - findCommentsByArticleId, - findCommentsByProductId, - createComment, - findCommentById, - updateComment, - deleteComment, -}; - -function Grogu() {} - -Grogu(); diff --git a/src/repository/comments.repository.js b/src/repository/comments.repository.js new file mode 100644 index 0000000..006e314 --- /dev/null +++ b/src/repository/comments.repository.js @@ -0,0 +1,81 @@ +import { prisma } from '../db/prisma.js'; + +async function createComment(data) { + return await prisma.comment.create({ data }); +} + +async function findCommentsWithCursor({ where, take, cursor }) { + const findManyOptions = { + take, + where, + orderBy: { + createdAt: 'desc', + }, + }; + + if (cursor) { + findManyOptions.cursor = { id: cursor }; // 커서의 시작점 + findManyOptions.skip = 1; // 커서 자체는 건너뛰기 + } + + const comments = await prisma.comment.findMany(findManyOptions); + + let nextCursor = null; + // 요청한 개수만큼 결과가 있다면, 다음 페이지가 있을 가능성이 있음 + if (comments.length === take) { + nextCursor = comments[comments.length - 1].id; + } + + return { comments, nextCursor }; +} + +async function findCommentsInArticle({ take, cursor }) { + return findCommentsWithCursor({ where: { parent: 'Article' }, take, cursor }); +} + +async function findCommentsByArticleId({ articleId, take, cursor }) { + return findCommentsWithCursor({ + where: { articleId: String(articleId) }, + take, + cursor, + }); +} + +async function findCommentsInProduct({ take, cursor }) { + return findCommentsWithCursor({ where: { parent: 'Product' }, take, cursor }); +} + +async function findCommentsByProductId({ productId, take, cursor }) { + return findCommentsWithCursor({ + where: { productId: String(productId) }, + take, + cursor, + }); +} + +async function findCommentById(id) { + return await prisma.comment.findUnique({ where: { id: String(id) } }); +} + +async function updateComment(id, data) { + return await prisma.comment.update({ where: { id: String(id) }, data }); +} + +async function deleteComment(id) { + return await prisma.comment.delete({ where: { id: String(id) } }); +} + +export const commentsRepository = { + findCommentsInArticle, + findCommentsInProduct, + findCommentsByArticleId, + findCommentsByProductId, + createComment, + findCommentById, + updateComment, + deleteComment, +}; + +function Grogu() {} + +Grogu(); diff --git a/src/repository/product.repository.js b/src/repository/products.repository.js similarity index 95% rename from src/repository/product.repository.js rename to src/repository/products.repository.js index b3e64be..730c611 100644 --- a/src/repository/product.repository.js +++ b/src/repository/products.repository.js @@ -23,7 +23,7 @@ async function deleteProduct(id) { return await prisma.product.delete({ where: { id: String(id) } }); } -export const productRepository = { +export const productsRepository = { createProduct, findProductById, findProductsMany, diff --git a/src/repository/users.repository.js b/src/repository/users.repository.js new file mode 100644 index 0000000..6cb69d4 --- /dev/null +++ b/src/repository/users.repository.js @@ -0,0 +1,61 @@ +import { prisma } from '../db/prisma.js'; + +async function findUserById(id) { + return prisma.user.findUnique({ + where: { + id, + }, + }); +} + +async function findUserByEmail(email) { + return await prisma.User.findUnique({ + where: { + email, + }, + }); +} + +async function createUser(user) { + return prisma.user.create({ + data: { + email: user.email, + nickname: user.nickname, + password: user.password, + }, + }); +} + +async function updateUserById(id, data) { + return prisma.user.update({ + where: { + id, + }, + data: data, + }); +} + +async function deleteUserById(id) { + return prisma.user.delete({ + where: { + id, + }, + }); +} + +async function upsertUser(provider, providerId, email, nickname) { + return prisma.user.upsert({ + where: { provider, providerId }, + update: { email, nickname }, + create: { provider, providerId, email, nickname }, + }); +} + +export default { + findUserById, + findUserByEmail, + createUser, + updateUserById, + upsertUser, + deleteUserById, +}; diff --git a/src/routes/article.js b/src/routes/articles.js similarity index 55% rename from src/routes/article.js rename to src/routes/articles.js index 42e0ae5..f901e8b 100644 --- a/src/routes/article.js +++ b/src/routes/articles.js @@ -1,16 +1,19 @@ import express from 'express'; -import { articleRepository as Article } from '../repository/article.repository.js'; +import { articlesRepository as Article } from '../repository/articles.repository.js'; import { validateArticles } from '../validators/validateArticles.js'; import { NotFoundException } from '../err/notFoundException.js'; -import { commentRepository as Comment } from '../repository/comment.repository.js'; +import { commentsRepository as Comment } from '../repository/comments.repository.js'; import { validateComments } from '../validators/validateComments.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { parsePagination } from '../middlewares/pagination.middleware.js'; export const articlesRouter = express.Router(); articlesRouter.get('/', async (req, res, next) => { try { const page = parseInt(req.query.page, 10) || 1; - const pageSize = parseInt(req.query.pageSize, 10) || 10; + const pageSize = + parseInt(req.query.pageSize, 10) || Number.MAX_SAFE_INTEGER; const keyword = req.query.keyword; const orderBy = req.query.orderBy; const offset = (page - 1) * pageSize; @@ -59,48 +62,60 @@ articlesRouter.get('/:id', async (req, res, next) => { if (!article) { throw new NotFoundException('글을 찾을 수 없습니다'); } - res.json({ success: true, data: article }); + res.json({ success: true, ...article }); } catch (err) { next(err); } }); -articlesRouter.post('/', validateArticles, async (req, res, next) => { - try { - const { title, content } = req.body; - const newArticle = await Article.createArticle({ - title, - content, - }); - res.status(201).json({ - success: true, - data: newArticle, - message: '글이 정상적으로 추가되었습니다', - }); - } catch (err) { - next(err); - } -}); +articlesRouter.post( + '/', + authMiddleware, + validateArticles, + async (req, res, next) => { + try { + const { title, content } = req.body; + const { id: authorId } = req.user; + const newArticle = await Article.createArticle({ + title, + content, + authorId, + }); + res.status(201).json({ + success: true, + message: '글이 정상적으로 추가되었습니다', + ...newArticle, + }); + } catch (err) { + next(err); + } + }, +); -articlesRouter.patch('/:id', validateArticles, async (req, res, next) => { - try { - const { id } = req.params; - const articleExistence = await Article.findArticleById(id); - if (!articleExistence) { - throw new NotFoundException('글을 찾을 수가 없습니다.'); +articlesRouter.patch( + '/:id', + authMiddleware, + validateArticles, + async (req, res, next) => { + try { + const { id } = req.params; + const articleExistence = await Article.findArticleById(id); + if (!articleExistence) { + throw new NotFoundException('글을 찾을 수가 없습니다.'); + } + const updatedArticle = await Article.updateArticle(id, req.body); + res.json({ + success: true, + message: '등록된 글 내용이 수정되었습니다', + ...updatedArticle, + }); + } catch (err) { + next(err); } - const updatedArticle = await Article.updateArticle(id, req.body); - res.json({ - success: true, - data: updatedArticle, - message: '등록된 글 내용이 수정되었습니다', - }); - } catch (err) { - next(err); - } -}); + }, +); -articlesRouter.delete('/:id', async (req, res, next) => { +articlesRouter.delete('/:id', authMiddleware, async (req, res, next) => { try { const { id } = req.params; const articleExistence = await Article.findArticleById(id); @@ -110,42 +125,51 @@ articlesRouter.delete('/:id', async (req, res, next) => { const deletedArticle = await Article.deleteArticle(id); res.json({ success: true, - data: deletedArticle, message: '글이 삭제되었습니다', + id: deletedArticle.id, }); } catch (err) { next(err); } }); -articlesRouter.get('/comments', async (req, res, next) => { +articlesRouter.get('/comments', parsePagination, async (req, res, next) => { try { - const [totalCount, comments] = await Comment.findCommentsInArticle(); + const { comments, nextCursor } = await Comment.findCommentsInArticle( + req.pagination, + ); res.json({ success: true, list: comments, - totalCount, + nextCursor, }); } catch (err) { next(err); } }); -articlesRouter.get('/:articleId/comments', async (req, res, next) => { - try { - const { articleId } = req.params; - const [totalCount, comments] = - await Comment.findCommentsByArticleId(articleId); +articlesRouter.get( + '/:articleId/comments', + parsePagination, + async (req, res, next) => { + try { + const { articleId } = req.params; + const { comments, nextCursor } = await Comment.findCommentsByArticleId({ + articleId, + ...req.pagination, + }); - res.json({ success: true, list: comments, totalCount }); - } catch (err) { - next(err); - } -}); + res.json({ success: true, list: comments, nextCursor }); + } catch (err) { + next(err); + } + }, +); articlesRouter.post( '/:articleId/comments', + authMiddleware, validateComments, async (req, res, next) => { try { @@ -165,8 +189,8 @@ articlesRouter.post( }); res.json({ success: true, - data: newArticleComment, message: '댓글이 정상적으로 추가되었습니다', + ...newArticleComment, }); } catch (err) { next(err); diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..d9fcc35 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,138 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import usersRepository from '../repository/users.repository.js'; +import { + validateUser, + validateLogin, + validateUpdate, +} from '../validators/validateUser.js'; +import { ConflictException } from '../err/conflictException.js'; +import { UnauthorizedException } from '../err/unauthorizedException.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { config } from '../config/config.js'; + +export const authRouter = express.Router(); + +// 회원가입 +authRouter.post('/signup', validateUser, async (req, res, next) => { + try { + const { email, nickname, password } = req.body; + + const existingUser = await usersRepository.findUserByEmail(email); + if (existingUser) { + throw new ConflictException('이미 사용중인 이메일입니다.'); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUser = await usersRepository.createUser({ + email, + nickname, + password: hashedPassword, + }); + + res.status(201).json({ + success: true, + message: '회원가입이 완료되었습니다.', + data: { + id: newUser.id, + email: newUser.email, + nickname: newUser.nickname, + }, + }); + } catch (err) { + next(err); + } +}); + +// 로그인 +authRouter.post('/login', validateLogin, async (req, res, next) => { + try { + const { email, password } = req.body; + const user = await usersRepository.findUserByEmail(email); + if (!user) { + throw new UnauthorizedException('이메일 또는 비밀번호를 확인해주세요.'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + throw new UnauthorizedException('이메일 또는 비밀번호를 확인해주세요.'); + } + + // Access Token 생성 (6시간) + const accessToken = jwt.sign({ userId: user.id }, config.JWT_SECRET, { + expiresIn: '6h', + }); + + // Refresh Token 생성 (1주일) + const refreshToken = jwt.sign({ userId: user.id }, config.JWT_SECRET, { + expiresIn: '1w', + }); + + // Refresh Token을 DB에 저장 + await usersRepository.updateUserById(user.id, { refreshToken }); + + res.cookie('authorization', `Bearer ${accessToken}`); + res.cookie('refreshToken', `Bearer ${refreshToken}`); + res.json({ success: true, message: '로그인에 성공했습니다.' }); + } catch (err) { + next(err); + } +}); + +// 로그아웃 +authRouter.post('/logout', authMiddleware, async (req, res, next) => { + try { + await usersRepository.updateUserById(req.user.id, { refreshToken: null }); + res.clearCookie('authorization'); + res.clearCookie('refreshToken'); + res.json({ success: true, message: '로그아웃 되었습니다.' }); + } catch (err) { + next(err); + } +}); + +// 내 정보 조회 +authRouter.get('/me', authMiddleware, (req, res) => { + const user = req.user; + res.json({ + success: true, + data: { id: user.id, email: user.email, name: user.name }, + }); +}); + +// 내 정보 수정 +authRouter.patch( + '/me', + authMiddleware, + validateUpdate, + async (req, res, next) => { + try { + const { id } = req.user; + const { name } = req.body; + const updatedUser = await usersRepository.updateUserById(id, { name }); + res.json({ + success: true, + message: '사용자 정보가 수정되었습니다.', + data: { id: updatedUser.id, name: updatedUser.name }, + }); + } catch (err) { + next(err); + } + }, +); + +// 회원 탈퇴 +authRouter.delete('/me', authMiddleware, async (req, res, next) => { + try { + const { id } = req.user; + await usersRepository.deleteUserById(id); + res.clearCookie('authorization'); + res.clearCookie('refreshToken'); + res.json({ success: true, message: '회원 탈퇴가 완료되었습니다.' }); + } catch (err) { + next(err); + } +}); diff --git a/src/routes/comment.js b/src/routes/comment.js deleted file mode 100644 index ce07d36..0000000 --- a/src/routes/comment.js +++ /dev/null @@ -1,42 +0,0 @@ -import express from 'express'; -import { commentRepository as Comment } from '../repository/comment.repository.js'; -import { validateComments } from '../validators/validateComments.js'; -import { NotFoundException } from '../err/notFoundException.js'; - -export const commentsRouter = express.Router(); - -commentsRouter.patch('/:id', validateComments, async (req, res, next) => { - try { - const { id } = req.params; - const commentExistence = await Comment.findCommentById(id); - if (!commentExistence) { - throw new NotFoundException('댓글을 찾을 수 없습니다'); - } - const updatedComment = await Comment.updateComment(id, req.body); - res.json({ - success: true, - data: updatedComment, - message: '등록된 글 내용이 수정되었습니다', - }); - } catch (err) { - next(err); - } -}); - -commentsRouter.delete('/:id', async (req, res, next) => { - try { - const { id } = req.params; - const commentExistence = await Comment.findCommentById(id); - if (!commentExistence) { - throw new NotFoundException('댓글을 찾을 수 없습니다'); - } - const deletedComment = await Comment.deleteComment(id); - res.json({ - success: true, - data: deletedComment, - message: '글이 삭제되었습니다', - }); - } catch (err) { - next(err); - } -}); diff --git a/src/routes/comments.js b/src/routes/comments.js new file mode 100644 index 0000000..d9699d5 --- /dev/null +++ b/src/routes/comments.js @@ -0,0 +1,48 @@ +import express from 'express'; +import { commentsRepository as Comment } from '../repository/comments.repository.js'; +import { validateComments } from '../validators/validateComments.js'; +import { NotFoundException } from '../err/notFoundException.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; + +export const commentsRouter = express.Router(); + +commentsRouter.patch( + '/:id', + authMiddleware, + validateComments, + async (req, res, next) => { + try { + const { id } = req.params; + const commentExistence = await Comment.findCommentById(id); + if (!commentExistence) { + throw new NotFoundException('댓글을 찾을 수 없습니다'); + } + const updatedComment = await Comment.updateComment(id, req.body); + res.json({ + success: true, + message: '등록된 글 내용이 수정되었습니다', + ...updatedComment, + }); + } catch (err) { + next(err); + } + }, +); + +commentsRouter.delete('/:id', authMiddleware, async (req, res, next) => { + try { + const { id } = req.params; + const commentExistence = await Comment.findCommentById(id); + if (!commentExistence) { + throw new NotFoundException('댓글을 찾을 수 없습니다'); + } + const deletedComment = await Comment.deleteComment(id); + res.json({ + success: true, + message: '글이 삭제되었습니다', + id: deletedComment.id, + }); + } catch (err) { + next(err); + } +}); diff --git a/src/routes/index.js b/src/routes/index.js index ce896a0..1e42957 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,7 +1,9 @@ import express from 'express'; import { productsRouter } from './products.js'; -import { articlesRouter } from './article.js'; -import { commentsRouter } from './comment.js'; +import { articlesRouter } from './articles.js'; +import { commentsRouter } from './comments.js'; +import { authRouter } from './auth.js'; +import { uploadsRouter } from './uploads.js'; export const router = express.Router(); @@ -17,3 +19,5 @@ router.get('/', (req, res) => { router.use('/products', productsRouter); router.use('/articles', articlesRouter); router.use('/comments', commentsRouter); +router.use('/auth', authRouter); +router.use('/uploads', uploadsRouter); diff --git a/src/routes/products.js b/src/routes/products.js index deb584e..8aa0a0f 100644 --- a/src/routes/products.js +++ b/src/routes/products.js @@ -1,9 +1,11 @@ import express from 'express'; -import { productRepository as Product } from '../repository/product.repository.js'; +import { productsRepository as Product } from '../repository/products.repository.js'; import { validateProducts } from '../validators/validateProducts.js'; import { NotFoundException } from '../err/notFoundException.js'; -import { commentRepository as Comment } from '../repository/comment.repository.js'; +import { commentsRepository as Comment } from '../repository/comments.repository.js'; import { validateComments } from '../validators/validateComments.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { parsePagination } from '../middlewares/pagination.middleware.js'; export const productsRouter = express.Router(); @@ -60,50 +62,63 @@ productsRouter.get('/:id', async (req, res, next) => { if (!product) { throw new NotFoundException('상품을 찾을 수 없습니다'); } - res.json({ success: true, data: product }); + res.json({ success: true, ...product }); } catch (err) { next(err); } }); -productsRouter.post('/', validateProducts, async (req, res, next) => { - try { - const { name, description, price, tags } = req.body; - const newProduct = await Product.createProduct({ - name, - description, - price, - tags, - }); - res.status(201).json({ - success: true, - data: newProduct, - message: '상품이 정상적으로 추가되었습니다', - }); - } catch (err) { - next(err); - } -}); +productsRouter.post( + '/', + authMiddleware, + validateProducts, + async (req, res, next) => { + try { + const { name, description, price, tags, images } = req.body; + const { id: authorId } = req.user; + const newProduct = await Product.createProduct({ + name, + description, + price, + tags, + images, + authorId, + }); + res.status(201).json({ + success: true, + message: '상품이 정상적으로 추가되었습니다', + ...newProduct, + }); + } catch (err) { + next(err); + } + }, +); -productsRouter.patch('/:id', validateProducts, async (req, res, next) => { - try { - const { id } = req.params; - const productExistence = await Product.findProductById(id); - if (!productExistence) { - throw new NotFoundException('상품을 찾을 수가 없습니다.'); +productsRouter.patch( + '/:id', + authMiddleware, + validateProducts, + async (req, res, next) => { + try { + const { id } = req.params; + const productExistence = await Product.findProductById(id); + if (!productExistence) { + throw new NotFoundException('상품을 찾을 수가 없습니다.'); + } + const updatedProduct = await Product.updateProduct(id, req.body); + res.json({ + success: true, + message: '등록된 상품 내용이 수정되었습니다', + ...updatedProduct, + }); + } catch (err) { + next(err); } - const updatedProduct = await Product.updateProduct(id, req.body); - res.json({ - success: true, - data: updatedProduct, - message: '등록된 상품 내용이 수정되었습니다', - }); - } catch (err) { - next(err); - } -}); + }, +); -productsRouter.delete('/:id', async (req, res, next) => { +productsRouter.delete('/:id', authMiddleware, async (req, res, next) => { try { const { id } = req.params; const productExistence = await Product.findProductById(id); @@ -113,42 +128,50 @@ productsRouter.delete('/:id', async (req, res, next) => { const deletedProduct = await Product.deleteProduct(id); res.json({ success: true, - data: deletedProduct, message: '상품이 삭제되었습니다', + id: deletedProduct.id, }); } catch (err) { next(err); } }); -productsRouter.get('/comments', async (req, res, next) => { +productsRouter.get('/comments', parsePagination, async (req, res, next) => { try { - const [totalCount, comments] = await Comment.findCommentsInProduct(); + const { comments, nextCursor } = await Comment.findCommentsInProduct( + req.pagination, + ); res.json({ success: true, list: comments, - totalCount, + nextCursor, }); } catch (err) { next(err); } }); -productsRouter.get('/:productId/comments', async (req, res, next) => { - try { - const { productId } = req.params; - const [totalCount, comments] = - await Comment.findCommentsByProductId(productId); - - res.json({ success: true, list: comments, totalCount }); - } catch (err) { - next(err); - } -}); +productsRouter.get( + '/:productId/comments', + parsePagination, + async (req, res, next) => { + try { + const { productId } = req.params; + const { comments, nextCursor } = await Comment.findCommentsByProductId({ + productId, + ...req.pagination, + }); + res.json({ success: true, list: comments, nextCursor }); + } catch (err) { + next(err); + } + }, +); productsRouter.post( '/:productId/comments', + authMiddleware, validateComments, async (req, res, next) => { try { @@ -168,8 +191,8 @@ productsRouter.post( }); res.json({ success: true, - data: newProductComment, message: '댓글이 정상적으로 추가되었습니다', + ...newProductComment, }); } catch (err) { next(err); diff --git a/src/routes/uploads.js b/src/routes/uploads.js new file mode 100644 index 0000000..2d96398 --- /dev/null +++ b/src/routes/uploads.js @@ -0,0 +1,46 @@ +import express from 'express'; +import multer from 'multer'; +import path from 'path'; + +export const uploadsRouter = express.Router(); + +// Multer 설정: 이미지 파일만 허용하고, 'uploads/' 디렉토리에 저장 +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, + fileFilter: (req, file, cb) => { + // 이미지 파일(jpeg, png, gif)만 허용 + if ( + file.mimetype === 'image/jpeg' || + file.mimetype === 'image/png' || + file.mimetype === 'image/gif' + ) { + cb(null, true); + } else { + cb(new Error('이미지 파일만 업로드 가능합니다.'), false); + } + }, +}); + +// 이미지 업로드 라우트 +// 클라이언트에서 'image'라는 key(field name)로 파일을 보내야 합니다. +uploadsRouter.post('/', upload.single('image'), (req, res) => { + if (!req.file) { + return res.status(400).json({ message: '파일이 업로드되지 않았습니다.' }); + } + // 클라이언트에게 파일이 저장된 경로를 반환 + res.json({ url: `/uploads/${req.file.filename}` }); +}); diff --git a/src/server.js b/src/server.js index 18ace19..24d6894 100644 --- a/src/server.js +++ b/src/server.js @@ -6,11 +6,21 @@ import { config, isDevelopment } from './config/config.js'; import { errorHandler } from './middlewares/errorHandler.js'; import { disconnectDB } from './db/prisma.js'; import { cors } from './middlewares/cors.js'; +import fs from 'fs'; +import path from 'path'; +import cookieParser from 'cookie-parser'; const app = express(); -app.use(express.json()); +// 'uploads' 디렉토리가 없으면 생성 +const uploadsDir = path.join(path.resolve(), 'uploads'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + console.log(`Created directory: ${uploadsDir}`); +} +app.use(express.json()); +app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); app.use(cors); @@ -24,6 +34,8 @@ app.use('/', router); app.use(errorHandler); +app.use('/uploads', express.static('uploads')); + const server = app.listen(config.PORT, () => { console.log(`🚀 Server running on http://localhost:${config.PORT}`); console.log(`📦 Environment: ${config.ENVIRONMENT}`); diff --git a/src/services/users.services.js b/src/services/users.services.js new file mode 100644 index 0000000..e69de29 diff --git a/src/validators/validateProducts.js b/src/validators/validateProducts.js index 3b0fe38..d245fc6 100644 --- a/src/validators/validateProducts.js +++ b/src/validators/validateProducts.js @@ -1,7 +1,7 @@ import { BadRequestException } from '../err/badRequestException.js'; export const validateProducts = (req, res, next) => { - const { name, description, price, tags } = req.body; + const { name, description, price, tags, image } = req.body; if (!name || name.trim().length > 10) { throw new BadRequestException( @@ -27,5 +27,9 @@ export const validateProducts = (req, res, next) => { throw new BadRequestException('태그는 존재해야 합니다.'); } + if (!image || !tags.length) { + throw new BadRequestException('상품 이미지는 존재해야 합니다.'); + } + next(); }; diff --git a/src/validators/validateUser.js b/src/validators/validateUser.js new file mode 100644 index 0000000..73bbb99 --- /dev/null +++ b/src/validators/validateUser.js @@ -0,0 +1,40 @@ +import { BadRequestException } from '../err/badRequestException.js'; + +const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/; + +export const validateUser = (req, res, next) => { + const { email, nickname, password } = req.body; + + if (!email || !emailRegex.test(email)) { + throw new BadRequestException('유효한 이메일 형식이 아닙니다.'); + } + if (!nickname) { + throw new BadRequestException('이름을 입력해주세요.'); + } + if (!password || password.length < 6) { + throw new BadRequestException('비밀번호는 6자 이상이어야 합니다.'); + } + + next(); +}; + +export const validateLogin = (req, res, next) => { + const { email, password } = req.body; + + if (!email) { + throw new BadRequestException('이메일을 입력해주세요.'); + } + if (!password) { + throw new BadRequestException('비밀번호를 입력해주세요.'); + } + + next(); +}; + +export const validateUpdate = (req, res, next) => { + const { nickname } = req.body; + if (!nickname) { + throw new BadRequestException('수정할 이름을 입력해주세요.'); + } + next(); +};